openvibers/openrestream Commits
OpenRe's webrtc worker (plan T4, transports step 2): WHIP ingest, the mediasoup SFU and viewer signaling in one worker kind (workers/webrtc.js + webrtc/{sfu,sdp,signaling}.js; UDP 10200-10300 beside Live's 10000-10100), a WHIP session going live on ICE connected as Live's handler does, browser broadcasters over /b/<key> and viewers over /w/<playback id>, a loopback RTP egress API (describe, PlainRTP consumer to a local port pair) for the restream worker, Media's RTP recorder and the thumbnail grabber. Recording a webrtc session goes through Media's RTP ingest (migration 0003: rtp_handle and the port pair); live thumbnails come from the worker that owns the source and are uploaded as Media objects (namespace live) and carried as thumbnail_url on openre.session.updated. Admission, leases and drains follow the rtmp/jsmpeg workers. The worker refuses to start in production without MEDIASOUP_ANNOUNCED_IP. mediasoup, sdp-transform and ws are dependencies, werift a dev dependency for test/webrtc-e2e.test.js (a real WebRTC client: WHIP offer to RTP egress, a counted viewer, recording through Media's RTP ingest, a browser broadcaster, refusals, DELETE, a lost lease, a drain). npm test 21/25 (4 PostgreSQL-only skips) and test:pg 25/25 (verified).
Changes
@@@ -44,9 +44,10 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRe's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** || Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** || Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** |--| Restream from a WebRTC source | same | encoder args ported (`encodingArgs`), source not (it needs that ingest) | not ported |--| Recording | `server/streaming/recorder.js` + `server/media-client.js` | `server/store/recordings.js` + `server/media-client.js` — same Media API v1 calls (create VOD, `ingest/rtmp`, finalize, delete shell / clips-only) driven by the coordinator; Media pulls the loopback play URL | **ported (RTMP)**, tested against a stub Media that really pulls the URL |--| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (werift + mediasoup; one worker owns all of it) | `workers/webrtc.js`: worker interface + coordinator registration only | **not ported** |++| Restream from a WebRTC source | same | `webrtcArgs()`/`rtpInputArgs()` — re-encodes a mediasoup PlainRTP consumer the owning worker sends to ffmpeg's ports (SDP written by the worker's egress API) | **ported, tested with real ffmpeg + mediasoup** |++| Recording | `server/streaming/recorder.js` + `server/media-client.js` | `server/store/recordings.js` + `server/media-client.js` — same Media API v1 calls (create VOD, `ingest/rtmp` for RTMP, `ingest/rtp/start|stop` for WebRTC, finalize, delete shell / clips-only) driven by the coordinator | **ported (RTMP + WebRTC)**, tested against a stub Media |++| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (mediasoup; one worker owns all of it) | `workers/webrtc.js` + `workers/webrtc/{sfu,sdp,signaling}.js` — WHIP (RFC 9725) admission by key, one mediasoup Router per session, `/b/<key>` broadcaster + `/w/<id>` viewer signaling, loopback RTP/SDP egress for restream/recording/thumbnails; SO_REUSEPORT drain | **ported, tested with real mediasoup** |++| Live thumbnails | `media-proxy/live-thumbs.js` | `workers/thumbnails.js` — the owning worker grabs a frame from its own source (FLV, MPEG-TS tap or a PlainRTP consumer) and uploads it to Media (object ns `live`); the session carries `thumbnail_url` | **ported, tested against a stub Media** || JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** || OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRe pushes to the stored key | **not ported** || Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |@@@ -139,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c## Playback--`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. `webrtc` and `hls` are `null` (not produced). The session API also reports `viewers` (connected JSMPEG viewers, from the worker heartbeat). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch).++`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRe's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.## Recording@@@ -165,6 +166,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).| 19360–19399/tcp (loopback) | OpenRe per-generation RTMP play + HTTP-FLV | permanent || **9736/tcp** | OpenRe `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` || 19710–19749/tcp (loopback) | OpenRe per-generation JSMPEG MPEG-TS data tap | permanent |++| **9936/tcp** | OpenRe `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |++| **10200–10300/udp** | OpenRe `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |++| 19810–19849/tcp (loopback) | OpenRe per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent || 4500/tcp (loopback) | `openre-api` | permanent |The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.@@@ -209,7 +213,7 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRe definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.--Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` instead); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.++Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.## Migration@@@ -244,9 +248,9 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d`workers/jsmpeg.js` is ported (key-in-path admission, WebSocket viewers by playback id, the MPEG-TS data tap, generations/drain), and `jsmpegArgs()` restreams it. docs/cutover.md has the JSMPEG slot procedure (J-A once, then J-B1–B6 per slot): set `OPENRE_JSMPEG_BIND=0.0.0.0`, open 9736 at the provider edge, add `jsmpeg` to Live's `OPENRE_PROTOCOLS`, then the per-slot window/switch/rollback. JSMPEG has **no recording** (parity with Live): a jsmpeg definition with recording on fails the request explicitly.--### WebRTC (not ready)++### WebRTC (worker ready)--One `workers/webrtc.js` will own WHIP ingest, the mediasoup producers/consumers and viewer signaling (decision 1: mediasoup is single-process, so ingest and consumption cannot be split). It needs its ported transport, a restream source for it, a `webrtc` playback descriptor, recording (RTP to Media) and `webrtc` in Live's `OPENRE_PROTOCOLS`. The same per-slot switch, window and rollback apply.++`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` and `DELETE`, one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.## Acceptance@@@ -98,3 +98,42 @@ server {proxy_set_header X-Forwarded-Proto $scheme;}}++++## ═══════════════════════════════════════════════════════════════++## REFERENCE (not installed): WHIP + WebRTC signaling over HTTPS at ingest.openre.stream.++## Opus installs this (or an equivalent) at the WebRTC cutover. It terminates TLS for++## `ingest.openre.stream` and proxies to the webrtc worker's public port (OPENRE_WEBRTC_PORT,++## default 9936): WHIP POST/PATCH/DELETE under /whip/* and the WS signalling endpoints /b/* and++## /w/*. The worker builds `Location: https://<host>/whip/session/<id>` from the request Host, so++## set proxy_set_header Host. The certificate must cover ingest.openre.stream. The RTC media itself++## does NOT go through nginx: it is UDP 10200-10300 straight to this host, so MEDIASOUP_ANNOUNCED_IP++## must be the public address and 10200-10300/udp must be open at the provider edge (grey-cloud DNS;++## Cloudflare's proxy cannot carry raw UDP WebRTC).++##++## server {++## listen 443 ssl;++## listen [::]:443 ssl;++## http2 on;++## server_name ingest.openre.stream;++## ssl_certificate /etc/letsencrypt/live/ingest.openre.stream/fullchain.pem;++## ssl_certificate_key /etc/letsencrypt/live/ingest.openre.stream/privkey.pem;++## ssl_protocols TLSv1.2 TLSv1.3;++##++## client_max_body_size 256k;++##++## location / {++## proxy_pass http://127.0.0.1:9936;++## proxy_http_version 1.1;++## proxy_set_header Host $host;++## proxy_set_header X-Real-IP $remote_addr;++## proxy_set_header X-Forwarded-For $remote_addr;++## proxy_set_header X-Forwarded-Proto $scheme;++## # WebSocket upgrade for /b/<key> and /w/<session id>++## proxy_set_header Upgrade $http_upgrade;++## proxy_set_header Connection $connection_upgrade;++## proxy_read_timeout 12h;++## send_timeout 12h;++## }++## }++## (Add `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` in http{} once.)++## ═══════════════════════════════════════════════════════════════@@@ -77,9 +77,9 @@ cmd_workers() {local sha=${1:-$(latest_release)}[ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"say "starting worker generation from release $sha (older generations drain by themselves)"-- systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service" "openre-jsmpeg@$sha.service"++ systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service" "openre-jsmpeg@$sha.service" "openre-webrtc@$sha.service"# Disable (not stop) older instances so a reboot does not bring them back; they exit on their own.-- for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' 'openre-jsmpeg@*' | awk '{print $1}'); do++ for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' 'openre-jsmpeg@*' 'openre-webrtc@*' | awk '{print $1}'); docase "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esacdone}nnew file mode 100644@@@ -0,0 +1,40 @@++[Unit]++Description=OpenRe.Stream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i++# One instance per release (instance name = the release directory). A worker deploy STARTS a new++# instance; the coordinator then drains the older generation, which closes its public listener,++# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.++# Nothing here depends on openre-api or openre-session-coordinator: an API deploy never restarts++# this unit. Never `systemctl restart` it during a broadcast; see README "Deploying".++# Needs MEDIASOUP_ANNOUNCED_IP (the public address) and UDP 10200-10300 open (see README "Port plan").++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openre.stream/releases/%i++EnvironmentFile=/etc/openvibe/openre.env++Environment=NODE_ENV=production++Environment=OPENRE_RELEASE=%i++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node workers/webrtc.js++# A crash restarts the instance as a new generation; a clean exit (drained) stays stopped.++Restart=on-failure++RestartSec=2++# SIGTERM starts a drain; it does not drop sessions. Only the main process gets it.++KillSignal=SIGTERM++KillMode=mixed++# An explicit stop (or host shutdown) waits this long for sessions to leave, then kills.++TimeoutStopSec=30min++LimitNOFILE=65536++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++ReadWritePaths=/var/lib/openre++# mediasoup spawns its own worker subprocesses and needs the RTC UDP range.++LimitNPROC=infinity++++[Install]++WantedBy=multi-user.target@@@ -373,7 +373,62 @@ Identical to B1–B6 with these substitutions:broadcaster points ffmpeg back at Live's relay. JSMPEG has **no recording** (parity with Live): ajsmpeg definition with recording on fails the request with `recording is not available for jsmpeg`.--## Phase C: bookkeeping (AGENT, after A2)++## WebRTC protocol cutover (WHIP + the SFU + viewer signaling)++++`workers/webrtc.js` carries the whole WebRTC stack (T4 decision 1: mediasoup is single-process, so++ingest and consumption live together): WHIP ingest, the mediasoup producers/consumers, and the++viewer signaling Live's broadcast/watch pages speak. Restream re-encodes a PlainRTP consumer's SDP,++recording goes through Media's RTP ingest, and thumbnails are produced by the worker. This has to be++done **once** (the worker generation and its ports), then per slot like every protocol.++++### W-A once (AGENT)++++- **Ports/firewall.** Open `9936/tcp` (WHIP + signaling; or put it behind nginx, see below) AND the++ mediasoup RTC range `10200–10300/udp` (never Live's 10000–10100). `ingest.openre.stream` stays the++ DNS-only record RTMP already uses; raw UDP cannot go through Cloudflare's proxy.++- **Env (`/etc/openvibe/openre.env`).** `OPENRE_WEBRTC_BIND=0.0.0.0`, `MEDIASOUP_ANNOUNCED_IP=<this++ host's public IP>` (required in production — the worker refuses to start without it),++ `OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT/MAX_PORT=10200/10300`.++ Each worker generation picks its own loopback egress port from `OPENRE_WEBRTC_INTERNAL_PORT_MIN..MAX`.++- **nginx (Opus).** Install the reference `ingest.openre.stream` server block in++ `deploy/nginx/openre.stream.conf` to terminate TLS in front of the worker's 9936, with the++ `Upgrade`/`Connection` map for `/b/*` and `/w/*`. Cert must cover `ingest.openre.stream`.++- **Deploy the worker.** `deploy.sh workers <sha>` now also starts `openre-webrtc@<sha>`; older++ generations drain and exit by themselves.++- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant++ `media.object.upload` ns `live`); RTP recording uses `ingestRtpStart`/`ingestRtpStop` (Media ports++ 12000–12199, already allocated by Media). Confirm both for OpenRe's token before switching a slot.++- **Live:** add `webrtc` to `OPENRE_PROTOCOLS` in `server/openre/authority.js` **only when the++ `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRe signaling++ endpoints (`sfu-*` messages are unchanged; the page reads `playback.webrtc.signaling_url`).++++### W-B1–W-B6 per slot (OWNER + broadcaster + AGENT)++++- **B1 Preconditions:** the slot's `managed_streams.protocol` is `webrtc` (or `whip`), the broadcast++ page sends `sfu-*` and not the legacy P2P messages, the slot is offline, `webrtc` is in Live's++ `OPENRE_PROTOCOLS`, and `openre-webrtc` is ready.++- **B2 Import:** `migrate-from-live.js` already imports `webrtc`/`whip` slots (protocols++ `['webrtc']`).++- **B3 Switch:** the same `PUT /api/admin/openre/managed/:id/ingest-authority {"authority":"openre"}`++ (rotates Live's own key in-transaction). Live's WHIP handler must refuse the slot key once++ `webrtc` is in `OPENRE_PROTOCOLS` (brief §4.5).++- **B4 Broadcaster:** regenerate the key, then either++ - **WHIP:** POST an SDP offer to `https://ingest.openre.stream/whip/<key>` (Bearer optional, must++ match) → `201` + the answer + `Location: /whip/session/<id>`; trickle with `PATCH`, end with++ `DELETE`. The session goes live when ICE connects.++ - **Browser:** connect `ws(s)://ingest.openre.stream/b/<key>` and run the mediasoup-client++ `sfu-get-capabilities → sfu-create-transport → sfu-connect-transport → sfu-produce` flow; the++ session goes live on the first video producer.++ - Viewers read the session playback descriptor (`webrtc.signaling_url`, keyed by playback id) and++ run `watch → sfu-viewer-create-transport → sfu-viewer-connect-transport → sfu-viewer-consume`.++- **B5/B6:** personal-key rotation unchanged; a test broadcast checks a viewer, a restream++ destination (re-encoded from the PlainRTP/SDP egress), a recording (Media RTP ingest) and a++ thumbnail (`thumbnail_url` on the session and in `openre.session.started|updated`).++- **B-rollback:** `{"authority":"live"}` + `set-definition-state.js --state disabled`, then the++ broadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the whole++ protocol rolls back.++++- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` into`/etc/openvibe/host.json`: `workerUnits`, and the drill block with `OPENRE_DRILL`. Then run@@@ -25,6 +25,7 @@],"eventsProduced": ["openre.session.started",++ "openre.session.updated","openre.session.ended","openre.session.failed","openre.output.healthy",nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: migrate++-- WebRTC recording through Media's RTP ingest (T4 decision 2 / brief §4.4). A webrtc session is++-- recorded by pointing a PlainRTP consumer at the RTP/RTCP port pair Media allocates; the++-- coordinator remembers the worker egress handle and the two ports so it can close the consumer on++-- finalize. Media's own RTP recorder needs codec info only at start, so nothing else changes here.++-- Never edited after it runs; a further change is a new migration.++ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_handle text COLLATE "C";++ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_video_port bigint;++ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_audio_port bigint;@@@ -14,14 +14,18 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",++ "mediasoup": "^3.14.14","node-media-server": "2.7.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sdp-transform": "^3.0.0",++ "ws": "^8.18.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "werift": "^0.24.4"},"engines": {"node": ">=22.12.0"@@@ -34,12 +38,314 @@"devOptional": true,"license": "Apache-2.0"},++ "node_modules/@fidm/asn1": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",++ "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 8"++ }++ },++ "node_modules/@fidm/x509": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",++ "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@fidm/asn1": "^1.0.4",++ "tweetnacl": "^1.0.1"++ },++ "engines": {++ "node": ">= 8"++ }++ },"node_modules/@iovalkey/commands": {"version": "0.1.0","resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz","integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==","license": "MIT"},++ "node_modules/@isaacs/fs-minipass": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",++ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",++ "license": "ISC",++ "dependencies": {++ "minipass": "^7.0.4"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@leichtgewicht/ip-codec": {++ "version": "2.0.5",++ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",++ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/@noble/curves": {++ "version": "1.9.7",++ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",++ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@noble/hashes": "1.8.0"++ },++ "engines": {++ "node": "^14.21.3 || >=16"++ },++ "funding": {++ "url": "https://paulmillr.com/funding/"++ }++ },++ "node_modules/@noble/hashes": {++ "version": "1.8.0",++ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",++ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": "^14.21.3 || >=16"++ },++ "funding": {++ "url": "https://paulmillr.com/funding/"++ }++ },++ "node_modules/@peculiar/asn1-cms": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz",++ "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "@peculiar/asn1-x509-attr": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-csr": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz",++ "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-ecc": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz",++ "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pfx": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz",++ "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.10.0",++ "@peculiar/asn1-pkcs8": "^2.10.0",++ "@peculiar/asn1-rsa": "^2.10.0",++ "@peculiar/asn1-schema": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pkcs8": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz",++ "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pkcs9": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz",++ "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.10.0",++ "@peculiar/asn1-pfx": "^2.10.0",++ "@peculiar/asn1-pkcs8": "^2.10.0",++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "@peculiar/asn1-x509-attr": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-rsa": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz",++ "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-schema": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz",++ "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/utils": "^2.0.2",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-x509": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz",++ "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/utils": "^2.0.2",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-x509-attr": {++ "version": "2.10.0",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz",++ "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.10.0",++ "@peculiar/asn1-x509": "^2.10.0",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/utils": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",++ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "tslib": "^2.8.1"++ }++ },++ "node_modules/@peculiar/x509": {++ "version": "1.14.3",++ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",++ "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.6.0",++ "@peculiar/asn1-csr": "^2.6.0",++ "@peculiar/asn1-ecc": "^2.6.0",++ "@peculiar/asn1-pkcs9": "^2.6.0",++ "@peculiar/asn1-rsa": "^2.6.0",++ "@peculiar/asn1-schema": "^2.6.0",++ "@peculiar/asn1-x509": "^2.6.0",++ "pvtsutils": "^1.3.6",++ "reflect-metadata": "^0.2.2",++ "tslib": "^2.8.1",++ "tsyringe": "^4.10.0"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@shinyoshiaki/binary-data": {++ "version": "0.6.1",++ "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz",++ "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "generate-function": "^2.3.1",++ "is-plain-object": "^2.0.3"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/@types/dom-mediacapture-transform": {++ "version": "0.1.12",++ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",++ "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@types/dom-webcodecs": "*"++ }++ },++ "node_modules/@types/dom-webcodecs": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",++ "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",++ "dev": true,++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -107,6 +413,21 @@"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==","license": "MIT"},++ "node_modules/asn1js": {++ "version": "3.0.10",++ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",++ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",++ "dev": true,++ "license": "BSD-3-Clause",++ "dependencies": {++ "pvtsutils": "^1.3.6",++ "pvutils": "^1.1.5",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=12.0.0"++ }++ },"node_modules/base64-js": {"version": "1.5.1","resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",@@@ -357,6 +678,15 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/data-uri-to-buffer": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",++ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",++ "license": "MIT",++ "engines": {++ "node": ">= 12"++ }++ },"node_modules/dateformat": {"version": "4.6.3","resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",@@@ -436,6 +766,19 @@"node": ">=8"}},++ "node_modules/dns-packet": {++ "version": "5.6.1",++ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",++ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@leichtgewicht/ip-codec": "^2.0.1"++ },++ "engines": {++ "node": ">=6"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -617,6 +960,29 @@],"license": "BSD-3-Clause"},++ "node_modules/fetch-blob": {++ "version": "3.2.0",++ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",++ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/jimmywarting"++ },++ {++ "type": "paypal",++ "url": "https://paypal.me/jimmywarting"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "node-domexception": "^1.0.0",++ "web-streams-polyfill": "^3.0.3"++ },++ "engines": {++ "node": "^12.20 || >= 14.13"++ }++ },"node_modules/file-uri-to-path": {"version": "1.0.0","resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",@@@ -641,6 +1007,24 @@"node": ">= 0.8"}},++ "node_modules/flatbuffers": {++ "version": "25.9.23",++ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",++ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",++ "license": "Apache-2.0"++ },++ "node_modules/formdata-polyfill": {++ "version": "4.0.10",++ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",++ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",++ "license": "MIT",++ "dependencies": {++ "fetch-blob": "^3.1.2"++ },++ "engines": {++ "node": ">=12.20.0"++ }++ },"node_modules/forwarded": {"version": "0.2.0","resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",@@@ -674,6 +1058,16 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/generate-function": {++ "version": "2.3.1",++ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",++ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "is-property": "^1.0.2"++ }++ },"node_modules/get-intrinsic": {"version": "1.3.0","resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",@@@ -729,6 +1123,45 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/h264-profile-level-id": {++ "version": "2.3.3",++ "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.3.tgz",++ "integrity": "sha512-rPd5gpyWuIfEbBiHC/f9I4wdeCpAhg0aWdYgjMRdVTqhJT22C6VrWTi4hpAAgJad1Vv8BJDEvrzAQTW7CxzmJw==",++ "license": "ISC",++ "dependencies": {++ "debug": "^4.4.3"++ },++ "engines": {++ "node": ">=22"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/mediasoup"++ }++ },++ "node_modules/h264-profile-level-id/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/h264-profile-level-id/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/has-flag": {"version": "4.0.0","resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",@@@ -890,6 +1323,36 @@"node": ">= 0.10"}},++ "node_modules/is-plain-object": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",++ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "isobject": "^3.0.1"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/is-property": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",++ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/isobject": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",++ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/json-schema-traverse": {"version": "1.0.0","resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",@@@ -1023,6 +1486,82 @@"node": ">= 0.6"}},++ "node_modules/mediabunny": {++ "version": "1.61.0",++ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.61.0.tgz",++ "integrity": "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A==",++ "dev": true,++ "license": "MPL-2.0",++ "workspaces": [++ ".",++ "packages/*"++ ],++ "dependencies": {++ "@types/dom-mediacapture-transform": "^0.1.11",++ "@types/dom-webcodecs": "0.1.13"++ },++ "funding": {++ "type": "individual",++ "url": "https://github.com/sponsors/Vanilagy"++ }++ },++ "node_modules/mediasoup": {++ "version": "3.27.1",++ "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.27.1.tgz",++ "integrity": "sha512-unjG0ihJFZmCXxtS5ay6iPp+ov1Rg/BhJuP1gGfw4ElX/K5Z0it3PCC7rKecWMK2FgbQ52kKmDgZBPm/ewYO6A==",++ "hasInstallScript": true,++ "license": "ISC",++ "dependencies": {++ "debug": "^4.4.3",++ "flatbuffers": "^25.9.23",++ "h264-profile-level-id": "^2.3.3",++ "node-fetch": "^3.3.2",++ "supports-color": "^11.0.0",++ "tar": "^7.5.22"++ },++ "engines": {++ "node": ">=22"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/mediasoup"++ }++ },++ "node_modules/mediasoup/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/mediasoup/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/mediasoup/node_modules/supports-color": {++ "version": "11.0.0",++ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-11.0.0.tgz",++ "integrity": "sha512-/zyImLdxhdygBIaVX0xTlQhKaCDLCrm665aqHk8xqK/Pa6k61fL6gwQGQq1k31yNyz6x55PppQgF2DMyPQa5xw==",++ "license": "MIT",++ "engines": {++ "node": ">=22"++ },++ "funding": {++ "url": "https://github.com/chalk/supports-color?sponsor=1"++ }++ },"node_modules/merge-descriptors": {"version": "1.0.3","resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",@@@ -1095,6 +1634,27 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/minipass": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",++ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",++ "license": "BlueOak-1.0.0",++ "engines": {++ "node": ">=16 || 14 >=14.17"++ }++ },++ "node_modules/minizlib": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",++ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",++ "license": "MIT",++ "dependencies": {++ "minipass": "^7.1.2"++ },++ "engines": {++ "node": ">= 18"++ }++ },"node_modules/mkdirp": {"version": "2.1.6","resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz",@@@ -1122,6 +1682,20 @@"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==","license": "MIT"},++ "node_modules/multicast-dns": {++ "version": "7.2.5",++ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",++ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "dns-packet": "^5.2.2",++ "thunky": "^1.0.2"++ },++ "bin": {++ "multicast-dns": "cli.js"++ }++ },"node_modules/napi-build-utils": {"version": "2.0.0","resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",@@@ -1149,6 +1723,44 @@"node": ">=10"}},++ "node_modules/node-domexception": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",++ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",++ "deprecated": "Use your platform's native DOMException instead",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/jimmywarting"++ },++ {++ "type": "github",++ "url": "https://paypal.me/jimmywarting"++ }++ ],++ "license": "MIT",++ "engines": {++ "node": ">=10.5.0"++ }++ },++ "node_modules/node-fetch": {++ "version": "3.3.2",++ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",++ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",++ "license": "MIT",++ "dependencies": {++ "data-uri-to-buffer": "^4.0.0",++ "fetch-blob": "^3.1.4",++ "formdata-polyfill": "^4.0.10"++ },++ "engines": {++ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/node-fetch"++ }++ },"node_modules/node-media-server": {"version": "2.7.4","resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.7.4.tgz",@@@ -1456,6 +2068,26 @@"once": "^1.3.1"}},++ "node_modules/pvtsutils": {++ "version": "1.3.6",++ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",++ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "tslib": "^2.8.1"++ }++ },++ "node_modules/pvutils": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",++ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=16.0.0"++ }++ },"node_modules/qs": {"version": "6.16.0","resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",@@@ -1546,6 +2178,13 @@"node": ">=4"}},++ "node_modules/reflect-metadata": {++ "version": "0.2.2",++ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",++ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",++ "dev": true,++ "license": "Apache-2.0"++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1581,6 +2220,15 @@"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==","license": "MIT"},++ "node_modules/sdp-transform": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",++ "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",++ "license": "MIT",++ "bin": {++ "sdp-verify": "checker.js"++ }++ },"node_modules/semver": {"version": "7.8.5","resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",@@@ -1815,6 +2463,22 @@"node": ">=8"}},++ "node_modules/tar": {++ "version": "7.5.22",++ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",++ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",++ "license": "BlueOak-1.0.0",++ "dependencies": {++ "@isaacs/fs-minipass": "^4.0.0",++ "chownr": "^3.0.0",++ "minipass": "^7.1.2",++ "minizlib": "^3.1.0",++ "yallist": "^5.0.0"++ },++ "engines": {++ "node": ">=18"++ }++ },"node_modules/tar-fs": {"version": "2.1.5","resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",@@@ -1843,6 +2507,22 @@"node": ">=6"}},++ "node_modules/tar/node_modules/chownr": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",++ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",++ "license": "BlueOak-1.0.0",++ "engines": {++ "node": ">=18"++ }++ },++ "node_modules/thunky": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",++ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",++ "dev": true,++ "license": "MIT"++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -1852,6 +2532,13 @@"node": ">=0.6"}},++ "node_modules/tslib": {++ "version": "2.8.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",++ "dev": true,++ "license": "0BSD"++ },"node_modules/tsscmp": {"version": "1.0.6","resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",@@@ -1861,6 +2548,26 @@"node": ">=0.6.x"}},++ "node_modules/tsyringe": {++ "version": "4.10.0",++ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",++ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "tslib": "^1.9.3"++ },++ "engines": {++ "node": ">= 6.0.0"++ }++ },++ "node_modules/tsyringe/node_modules/tslib": {++ "version": "1.14.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",++ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",++ "dev": true,++ "license": "0BSD"++ },"node_modules/tunnel-agent": {"version": "0.6.0","resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",@@@ -1873,6 +2580,13 @@"node": "*"}},++ "node_modules/tweetnacl": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",++ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",++ "dev": true,++ "license": "Unlicense"++ },"node_modules/type-is": {"version": "1.6.18","resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",@@@ -1919,6 +2633,86 @@"node": ">= 0.8"}},++ "node_modules/web-streams-polyfill": {++ "version": "3.3.3",++ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",++ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",++ "license": "MIT",++ "engines": {++ "node": ">= 8"++ }++ },++ "node_modules/werift": {++ "version": "0.24.4",++ "resolved": "https://registry.npmjs.org/werift/-/werift-0.24.4.tgz",++ "integrity": "sha512-NoROZ11L/ZqAB5ombCB4VBuVNdIZfwI493zlxlIX7BlwfWRy55eDJdtWMC2VX35yBXFaWwA8NtYbmJ8qWtVk9Q==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "@fidm/x509": "^1.2.1",++ "@noble/curves": "^1.8.1",++ "@peculiar/x509": "^1.12.3",++ "@shinyoshiaki/binary-data": "^0.6.1",++ "buffer": "^6.0.3",++ "debug": "4.4.0",++ "mediabunny": "^1.45.2",++ "multicast-dns": "^7.2.5",++ "tweetnacl": "^1.0.3"++ },++ "engines": {++ "node": ">=16"++ }++ },++ "node_modules/werift/node_modules/buffer": {++ "version": "6.0.3",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",++ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.2.1"++ }++ },++ "node_modules/werift/node_modules/debug": {++ "version": "4.4.0",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",++ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/werift/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "dev": true,++ "license": "MIT"++ },"node_modules/wrappy": {"version": "1.0.2","resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",@@@ -1954,6 +2748,15 @@"engines": {"node": ">=0.4"}++ },++ "node_modules/yallist": {++ "version": "5.0.0",++ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",++ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",++ "license": "BlueOak-1.0.0",++ "engines": {++ "node": ">=18"++ }}}}@@@ -30,16 +30,20 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",++ "mediasoup": "^3.14.14","node-media-server": "2.7.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sdp-transform": "^3.0.0",++ "ws": "^8.18.0"},"engines": {"node": ">=22.12.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "werift": "^0.24.4"}}@@@ -84,6 +84,7 @@ function createV1Router({ rt, auth }) {failure_reason: s.failure_reason,media_info: s.media_info,viewers: s.viewers || 0,++ thumbnail_url: s.thumbnail_url || null,revision: s.revision,created_at: iso(s.created_at),live_at: iso(s.live_at),@@@ -23,6 +23,7 @@ function load(env = process.env) {const port = int(env.PORT, 4500);const rtmpPort = int(env.OPENRE_RTMP_PORT, 1936);const jsmpegPort = int(env.OPENRE_JSMPEG_PORT, 9736);++ const webrtcPort = int(env.OPENRE_WEBRTC_PORT, 9936);return {service: 'openre',port,@@@ -109,6 +110,57 @@ function load(env = process.env) {maxPublishersPerWorker: int(env.OPENRE_JSMPEG_MAX_PUBLISHERS, 64),},++ // ── Ingest (WebRTC: WHIP + the mediasoup SFU + viewer signaling) ──++ // One worker kind 'webrtc' owns WHIP ingest, the mediasoup producers/consumers and viewer++ // signaling (T4 decision 1: mediasoup is single-process, so ingest and consumption cannot be++ // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRe listens on++ // 10200–10300 (decision 3) so both stacks run side by side.++ webrtc: {++ // Public HTTP port: WHIP (POST/PATCH/DELETE /whip/…) and the WS signaling endpoints++ // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRe brings its own.++ // SO_REUSEPORT so two generations can listen during a drain.++ port: webrtcPort,++ bindHost: env.OPENRE_WEBRTC_BIND || '0.0.0.0',++ publicHost: env.OPENRE_WEBRTC_PUBLIC_HOST || (isProduction ? 'ingest.openre.stream' : '127.0.0.1'),++ publicPort: int(env.OPENRE_WEBRTC_PUBLIC_PORT, webrtcPort),++ // Loopback HTTP API a generation serves RTP descriptors from: the restream worker, Media's++ // recorder and the thumbnail grabber ask it for a PlainRTP feed of a session's producers.++ // First free port from this range; published in the worker row.++ internalPortMin: int(env.OPENRE_WEBRTC_INTERNAL_PORT_MIN, 19810),++ internalPortMax: int(env.OPENRE_WEBRTC_INTERNAL_PORT_MAX, 19849),++ maxPublishersPerWorker: int(env.OPENRE_WEBRTC_MAX_PUBLISHERS, 64),++ // STUN/TURN servers handed to browser broadcasters and viewers (Live's SFU uses Google's++ // public STUN; TURN_URL on Live is a later addition). Comma-separated list of URLs.++ stunUrls: list(env.OPENRE_WEBRTC_STUN, ['stun:stun.l.google.com:19302']),++ // mediasoup: one Worker per process (more only if OPENRE_MEDIASOUP_WORKERS says so), one++ // Router per session. announcedIp (MEDIASOUP_ANNOUNCED_IP) is required in production: the++ // public address candidates are advertised with, without which remote WebRTC clients cannot++ // reach the worker. Never Live's 10000–10100 range.++ media: {++ listenIp: env.MEDIASOUP_LISTEN_IP || env.OPENRE_MEDIASOUP_LISTEN_IP || '0.0.0.0',++ announcedIp: env.MEDIASOUP_ANNOUNCED_IP || env.OPENRE_MEDIASOUP_ANNOUNCED_IP || '',++ minPort: int(env.OPENRE_MEDIASOUP_MIN_PORT, 10200),++ maxPort: int(env.OPENRE_MEDIASOUP_MAX_PORT, 10300),++ workers: Math.max(1, int(env.OPENRE_MEDIASOUP_WORKERS, 1)),++ // Same codec set Live's SFU offers (server/streaming/webrtc-sfu.js + config.js).++ mediaCodecs: [++ { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },++ { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, parameters: { 'x-google-start-bitrate': 2500 } },++ { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f', 'level-asymmetry-allowed': 1 } },++ ],++ },++ // Live thumbnails come from the source the worker owns (decision 4): a frame grabbed every++ // interval from a short-lived consumer, uploaded to Media and carried on the session state++ // and its events as thumbnail_url.++ thumbnails: {++ enabled: env.OPENRE_THUMBNAILS !== 'off',++ intervalMs: int(env.OPENRE_THUMBNAIL_INTERVAL_MS, 30000),++ width: int(env.OPENRE_THUMBNAIL_WIDTH, 640),++ // Namespace the object is uploaded under in Media (grant media.object.upload ns live).++ namespace: env.OPENRE_THUMBNAIL_NS || 'live',++ },++ },++// ── Workers, leases, generations ────────────────────────workers: {heartbeatMs: int(env.OPENRE_WORKER_HEARTBEAT_MS, 2000),@@@ -9,6 +9,7 @@** Event types (source "openre"):* openre.session.started a session reached live subject ingest_session++ * openre.session.updated a live session's thumbnail_url changed subject ingest_session* openre.session.ended a session that had been live ended subject ingest_session* openre.session.failed a session failed (worker lost, error) subject ingest_session* openre.output.healthy a restream output confirmed live subject output@@@ -24,6 +25,7 @@ const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');const TYPES = Object.freeze({sessionStarted: 'openre.session.started',++ sessionUpdated: 'openre.session.updated',sessionEnded: 'openre.session.ended',sessionFailed: 'openre.session.failed',outputHealthy: 'openre.output.healthy',@@@ -58,8 +58,44 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {appId: config.media.appId,createVod: async (fields) => await request('POST', '/vods', { body: fields }),ingestRtmp: async (vodId, rtmpUrl) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),++ /** Media RTP ingest: allocate a local RTP/RTCP port pair Media listens on (Live server/streaming/recorder.js). */++ ingestRtpStart: async (vodId, { video, audio } = {}) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtp/start`, { body: { video, audio }, timeoutMs: 30000 }),++ ingestRtpStop: async (vodId) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtp/stop`, { timeoutMs: 30000 }),finalizeVod: async (vodId) => await request('POST', `/vods/${encodeURIComponent(vodId)}/finalize`, { timeoutMs: 30000 }),deleteVod: async (vodId) => await request('DELETE', `/vods/${encodeURIComponent(vodId)}`),++ /**++ * Upload bytes as a Media object (canonical object API /api/v2/:app/objects, Live/Media:++ * docs/object-model.md): init → presigned PUT → complete. Used for live thumbnails under++ * the `live` namespace (grant media.object.upload ns live). Returns the object's public URL.++ */++ uploadObject: async ({ namespace, kind = 'image', visibility = 'public', mimeType, filename, bytes, metadata } = {}) => {++ const objectsBase = `${config.media.url}/api/v2/${encodeURIComponent(config.media.appId)}/objects`;++ const headers = { Accept: 'application/json', ...(await authHeader()), 'Content-Type': 'application/json' };++ const post = async (url, body) => {++ const res = await fetchImpl(url, { method: 'POST', headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });++ const text = await res.text().catch(() => '');++ let json = null;++ if (text) { try { json = JSON.parse(text); } catch { json = null; } }++ if (!res.ok) throw new MediaError((json && (json.error || json.detail)) || `Media ${res.status} on POST ${url}`, res.status, json);++ return json;++ };++ const init = await post(objectsBase, {++ kind, namespace, visibility, mime_type: mimeType, filename,++ size_bytes: bytes.length, metadata: { source: 'openre', ...(metadata || {}) },++ });++ const upload = init && init.upload;++ if (!upload || !upload.url) throw new MediaError('Media returned no upload URL for the object', 0, init);++ const put = await fetchImpl(upload.url, {++ method: upload.method || 'PUT',++ headers: { 'Content-Type': mimeType || 'application/octet-stream', 'Content-Length': String(bytes.length) },++ body: bytes,++ signal: AbortSignal.timeout(30000),++ });++ if (!put.ok) throw new MediaError(`Media object upload ${put.status}`, put.status, null);++ const done = await post(upload.complete_url);++ const object = (done && done.object) || done || {};++ return { id: object.id || init.id, url: object.public_url || object.publicUrl || null };++ },};}@@@ -219,7 +219,7 @@ function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}if (s.reason && s.status !== 'imported') lines.push(`- ${s.status}: ${s.reason}`);if (s.status === 'imported') {if (s.protocol === 'webrtc' || s.protocol === 'whip') {-- lines.push(`- Slot protocol is ${s.protocol}${s.streaming_method ? ` / ${s.streaming_method}` : ''}: the webrtc worker is not ported yet; leave this slot on Live until it ships.`);++ lines.push(`- Slot protocol is ${s.protocol}${s.streaming_method ? ` / ${s.streaming_method}` : ''}: switch it after the WebRTC worker is live (docs/cutover.md, WebRTC; WHIP ingest + the mediasoup SFU + viewer signaling).`);} else if (s.protocol === 'jsmpeg') {lines.push(`- Slot protocol is jsmpeg${s.streaming_method ? ` / ${s.streaming_method}` : ''}: switch it after the JSMPEG worker generation is running (docs/cutover.md, JSMPEG).`);}@@@ -272,6 +272,19 @@ function createDefinitions({ db, config, events, clock }) {key_id: active ? active.id : null,};}++ if (definition.protocols.includes('webrtc')) {++ const w = config.webrtc;++ const tls = w.publicPort === 443;++ const host = `${w.publicHost}${tls ? '' : `:${w.publicPort}`}`;++ out.webrtc = {++ // WHIP ingest (POST the SDP offer to <whip_url>/<key>) and the browser broadcaster's++ // signaling endpoint (join /b/<key> and run the mediasoup-client sfu-* flow).++ whip_url: `${tls ? 'https' : 'http'}://${host}/whip`,++ signaling_url: `${tls ? 'wss' : 'ws'}://${host}/b`,++ key_hint: active ? active.hint : null,++ key_id: active ? active.id : null,++ };++ }return out;}@@@ -30,13 +30,13 @@ function serializable(db) {});}--function createStore({ db, config, events, clock, box, log = console }) {++function createStore({ db, config, events, clock, box, log = console, fetchImpl = globalThis.fetch }) {db = serializable(db);const definitions = createDefinitions({ db, config, events, clock });const workers = createWorkers({ db, config, clock });const sessions = createSessions({ db, config, events, clock, definitions, workers });const outputs = createOutputs({ db, config, events, clock, box, definitions, sessions });-- const recordings = createRecordings({ db, config, events, clock, definitions, sessions, log });++ const recordings = createRecordings({ db, config, events, clock, definitions, sessions, workers, log, fetchImpl });return { definitions, workers, sessions, outputs, recordings };}@@@ -44,7 +44,7 @@ async function openRuntime({ config, clock = { now: () => Date.now() }, fetchImpdb = serializable(db || await openDb(config, { log }));const events = createEvents({ config, db, fetchImpl, now: () => clock.now(), log });const box = createBox({ key: config.secretsKey, previous: config.secretsKeyPrevious });-- const store = createStore({ db, config, events, clock, box, log });++ const store = createStore({ db, config, events, clock, box, log, fetchImpl });return { db, events, box, store, clock, config };}@@@ -24,7 +24,7 @@ const MAX_ATTEMPTS = 10;const DISK_RETRY_MS = 5 * 60 * 1000;const DISK_MAX_ATTEMPTS = 12;--function createRecordings({ db, config, events, clock, definitions, sessions, log = console }) {++function createRecordings({ db, config, events, clock, definitions, sessions, workers, log = console, fetchImpl = globalThis.fetch }) {const now = () => clock.now();const q = {get: db.prepare('SELECT * FROM recordings WHERE id = ?'),@@@ -65,6 +65,113 @@ function createRecordings({ db, config, events, clock, definitions, sessions, loreturn r ? r.id : undefined;}++ /** The egress base URL of the worker that owns this session (its RTP descriptor API), or null. */++ async function egressBase(session) {++ const owner = session.worker_id ? await workers.get(session.worker_id) : null;++ const ep = (owner && owner.endpoints) || {};++ return ep.egressPort ? `http://127.0.0.1:${ep.egressPort}` : null;++ }++++ async function fetchJson(url, opts) {++ const res = await fetchImpl(url, { ...opts, signal: AbortSignal.timeout(10000) });++ const text = await res.text().catch(() => '');++ let json = null;++ if (text) { try { json = JSON.parse(text); } catch { json = null; } }++ if (!res.ok) { const e = new Error((json && json.error) || `egress ${res.status}`); e.status = res.status; throw e; }++ return json;++ }++++ /** Stop the worker sending RTP to Media: close the PlainRTP consumer it opened for us. */++ async function closeWebrtcEgress(rec, session) {++ if (!rec.rtp_handle) return;++ const base = await egressBase(session);++ if (base) { try { await fetchJson(`${base}/rtp/${session.id}/${rec.rtp_handle}`, { method: 'DELETE' }); } catch { /* best effort */ } }++ await update(rec.id, { rtp_handle: null });++ rec.rtp_handle = null;++ }++++ /**++ * Record a webrtc session through Media's RTP ingest (brief §4.4): Media allocates a video (and++ * optional audio) RTP/RTCP port pair, the owning worker opens a PlainRTP consumer sending to it,++ * and on finalize the consumer is closed and ingestRtpStop/finalize close the Media recording.++ */++ async function stepWebrtc(rec, media, session, definition) {++ const ended = isTerminal(session.state) || session.state === 'ending';++ const base = await egressBase(session);++++ if (rec.state === 'pending') {++ if (ended) return await update(rec.id, { state: 'cancelled' });++ if (!base) return await retryLater(rec, new Error('the WebRTC worker exposes no egress endpoint yet'));++ let desc;++ try { desc = await fetchJson(`${base}/rtp/${session.id}/describe`); } catch (err) { return await retryLater(rec, err); }++ if (!desc.video) return await retryLater(rec, new Error('no WebRTC video producer yet'));++ let vodId = rec.media_vod_id;++ try {++ if (!vodId) {++ const liveUser = refOf(definition, 'live', 'user');++ const liveSlot = refOf(definition, 'live', 'managed_stream');++ const out = await media.createVod({++ title: definition.title || 'Stream Recording',++ user_id: liveUser != null ? Number(liveUser) : undefined,++ managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,++ clips_only: rec.mode === 'clips' || undefined,++ visibility: definition.recording_visibility,++ meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: 'webrtc', mode: rec.mode },++ });++ vodId = String(out && out.id);++ rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });++ }++ const ports = await media.ingestRtpStart(vodId, { video: desc.video, audio: desc.audio || undefined });++ const videoPort = ports && (ports.videoPort || ports.video_port);++ const audioPort = ports && (ports.audioPort || ports.audio_port);++ if (!videoPort) throw new Error('Media returned no RTP ports for the recording');++ const egress = await fetchJson(`${base}/rtp/${session.id}?vport=${videoPort}${audioPort ? `&aport=${audioPort}` : ''}`);++ await update(rec.id, { rtp_handle: egress.handle || null, rtp_video_port: videoPort, rtp_audio_port: audioPort || null });++ } catch (err) {++ log.warn(`[recording] ${rec.id} (webrtc) request failed: ${err.message}`);++ if (vodId) await media.deleteVod(vodId).catch(() => {});++ await update(rec.id, { media_vod_id: null, state: 'pending' });++ return await retryLater(await q.get.get(rec.id), err, { disk: /disk/i.test(String(err.message)) });++ }++ return await db.tx(async () => {++ const r = await update(rec.id, { state: 'recording', attempts: 0, last_error: null, next_attempt_at: 0 });++ await events.enqueue({++ event_type: TYPES.recordingRequested,++ actor: { type: 'service', id: 'openre' },++ subject: { type: 'recording', id: rec.id, revision: 1 },++ visibility: 'internal',++ priority: 'important',++ payload: {++ recording_id: rec.id,++ session_id: session.id,++ stream_id: definition.id,++ owner: { type: 'user', id: definition.owner_subject },++ mode: rec.mode,++ media: { app: config.media.appId, vod_id: r.media_vod_id },++ external_refs: definition.external_refs,++ },++ });++ return r;++ });++ }++++ if (rec.state === 'requested') return await update(rec.id, { state: 'pending' });++++ if (rec.state === 'recording' && ended) rec = await update(rec.id, { state: 'finalizing' });++ if (rec.state === 'finalizing') {++ await closeWebrtcEgress(rec, session);++ try { await media.ingestRtpStop(rec.media_vod_id); } catch (err) { if (err.status !== 409) return await retryLater(rec, err); }++ try {++ await media.finalizeVod(rec.media_vod_id);++ if (rec.mode === 'clips') await media.deleteVod(rec.media_vod_id).catch(() => {});++ } catch (err) {++ if (err.status !== 409) return await retryLater(rec, err);++ }++ return await update(rec.id, { state: 'finalized', last_error: null });++ }++ return rec;++ }++async function step(rec, media) {const session = await sessions.get(rec.session_id);if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });@@@ -94,6 +201,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, lo});}++ if (session.protocol === 'webrtc') return await stepWebrtc(rec, media, session, definition);++if (rec.state === 'pending') {if (ended) return await update(rec.id, { state: 'cancelled' });const pb = await sessions.playback(session);@@@ -31,6 +31,9 @@ function createSessions({ db, config, events, clock, definitions, workers }) {// Viewer counts live inside media_info so no column is needed: merge, never replace, so the// width/height the JSMPEG ingest path reported survive.viewers: db.prepare(`UPDATE ingest_sessions SET media_info = jsonb_set(COALESCE(NULLIF(media_info, '')::jsonb, '{}'::jsonb), '{viewers}', to_jsonb(?::bigint), true)::text WHERE id = ? AND state IN ${OPEN}`),++ // The live frame the owning worker uploaded to Media (decision 4): merged, like viewers, so++ // the width/height a protocol reported survive.++ thumbnail: db.prepare(`UPDATE ingest_sessions SET media_info = jsonb_set(COALESCE(NULLIF(media_info, '')::jsonb, '{}'::jsonb), '{thumbnail_url}', to_jsonb(?::text), true)::text WHERE id = ? AND state IN ${OPEN}`),requestEnd: db.prepare(`UPDATE ingest_sessions SET desired_state = 'end', end_requested_by = ?, updated_at = ? WHERE id = ? AND state IN ${OPEN}`),endRequests: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND desired_state = 'end' AND state IN ${OPEN}`),ofWorker: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND state IN ${OPEN}`),@@@ -50,7 +53,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {function shape(s) {if (!s) return null;const media_info = parseJson(s.media_info, null);-- return { ...s, media_info, viewers: (media_info && Number(media_info.viewers)) || 0 };++ return { ...s, media_info, viewers: (media_info && Number(media_info.viewers)) || 0, thumbnail_url: (media_info && media_info.thumbnail_url) || null };}async function get(id) {@@@ -138,7 +141,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {const session = shape(await q.get.get(current.id));const definition = await definitions.row(session.definition_id);if (to === 'live') {-- await events.enqueue(await envelopeFor(TYPES.sessionStarted, session, definition, { playback: await playback(session) }));++ await events.enqueue(await envelopeFor(TYPES.sessionStarted, session, definition, { thumbnail_url: session.thumbnail_url || null, playback: await playback(session) }));} else if (to === 'ended' && session.live_at) {await events.enqueue(await envelopeFor(TYPES.sessionEnded, session, definition, {ended_at: iso(session.ended_at),@@@ -201,6 +204,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {state: session.state,live: session.state === 'live',worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation, state: worker.state } : null,++ thumbnail_url: session.thumbnail_url || null,flv: null,rtmp: null,webrtc: null,@@@ -228,6 +232,14 @@ function createSessions({ db, config, events, clock, definitions, workers }) {if (session.protocol === 'rtmp' && ep.rtmpPlayPort) {descriptor.rtmp = { internal_url: `rtmp://127.0.0.1:${ep.rtmpPlayPort}/live/${session.id}` };}++ if (session.protocol === 'webrtc' && ep.publicPort) {++ // The viewer signaling endpoint, keyed by the session's playback id (never the key), and++ // the mediasoup announced IP clients must be able to reach for media to flow.++ descriptor.webrtc = {++ signaling_url: `ws://${config.webrtc.publicHost}:${ep.publicPort}/w/${session.id}`,++ announced_ip: config.webrtc.media.announcedIp || null,++ };++ }return descriptor;}@@@ -242,6 +254,22 @@ function createSessions({ db, config, events, clock, definitions, workers }) {playback,setMediaInfo: async (id, info) => (await q.mediaInfo.run(JSON.stringify(info), id)).changes > 0,setViewers: async (id, count) => (await q.viewers.run(Math.max(0, Number(count) || 0), id)).changes > 0,++ /**++ * Record the live frame's Media URL (decision 4) and emit openre.session.updated. A no-op++ * when the URL did not change, so the periodic grab does not publish an event every interval.++ */++ setThumbnail: async (id, url) => await db.tx(async () => {++ const current = await q.get.get(id);++ if (!current) return false;++ const currentUrl = (parseJson(current.media_info, {}) || {}).thumbnail_url || null;++ if (currentUrl === url) return false;++ const changed = (await q.thumbnail.run(url || '', id)).changes > 0;++ if (!changed) return false;++ const session = shape(await q.get.get(id));++ const definition = await definitions.row(session.definition_id);++ await events.enqueue(await envelopeFor(TYPES.sessionUpdated, session, definition, { thumbnail_url: url || null }));++ return true;++ }),transitions: async (id) => await q.transitions.all(id),endRequestsFor: async (workerId) => (await q.endRequests.all(workerId)).map(shape),ofWorker: async (workerId) => (await q.ofWorker.all(workerId)).map(shape),@@@ -11,7 +11,7 @@ const { createMediaClient } = require('../server/media-client');const { createCoordinator } = require('../server/coordinator');const { createRtmpIngest } = require('../workers/rtmp-ingest');const { createRestreamWorker } = require('../workers/restream-worker');--const { createUnportedTransport } = require('../workers/unported-transport');++const { createWebrtc } = require('../workers/webrtc');const { createJsmpeg } = require('../workers/jsmpeg');const { openDb } = require('../server/db');const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite, silent, testDb } = require('./helpers');@@@ -46,7 +46,7 @@ t('the coordinator and every transport worker refuse to be created in a drill',drill(() => createCoordinator({ rt, media: null, log: silent }));drill(() => createRtmpIngest({ rt, log: silent, exit: () => {} }));drill(() => createRestreamWorker({ rt, log: silent, exit: () => {} }));-- drill(() => createUnportedTransport({ rt, kind: 'webrtc', log: silent, exit: () => {} }));++ drill(() => createWebrtc({ rt, log: silent, exit: () => {} }));drill(() => createJsmpeg({ rt, log: silent, exit: () => {} }));assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM workers').get()).n, 0, 'no generation registered');assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM leases').get()).n, 0, 'no coordinator lease taken');@@@ -195,7 +195,9 @@ t('the JSMPEG data tap restreams to a destination', async () => {t('recording is refused with a clear reason, without calling Media', async () => {const s = await waitFor(async () => { const d = await detail(sessionA.id); return d.recording && d.recording.state === 'failed' ? d : null; }, { what: 'recording failed', timeoutMs: 10000 });assert.strictEqual(s.recording.last_error, 'recording is not available for jsmpeg');-- assert.strictEqual(mediaCalls.length, 0, 'Media was never asked to record a jsmpeg session');++ // The worker does upload live-thumbnail objects (decision 4: `POST /api/v2/<app>/objects`), so++ // only the recording endpoints must be untouched.++ assert.strictEqual(mediaCalls.filter((c) => !/\/objects$/.test(c.url)).length, 0, 'Media was never asked to record a jsmpeg session');});t('a second publisher with the same key is refused while the first is live', async () => {@@@ -115,7 +115,7 @@ t('apply: slots with a subject are imported with new keys; the rest are held orassert.match(md, /## @japaneseoldguy/);assert.match(md, /ingest-authority/);assert.match(md, /OAuth-linked/);-- assert.match(md, /webrtc worker is not ported/, 'the WebRTC slot is flagged');++ assert.match(md, /switch it after the WebRTC worker is live/, 'the WebRTC slot is flagged');for (const secret of ['a3f9c2leakedkey', 'live_twitchsecret_1', 'yt-secret-2222', 'personalkey111', 'kick-secret-44']) assert.ok(!md.includes(secret), `no ${secret} in the checklist`);});nnew file mode 100644@@@ -0,0 +1,298 @@++'use strict';++// End to end for the WebRTC worker with real mediasoup and a real headless WebRTC client (werift, the++// Node peer Live also uses): a WHIP publisher's offer is answered and goes live when ICE connects, a++// PlainRTP egress carries its RTP, a WS viewer consumes by playback id and is counted in the++// heartbeat, a browser broadcaster produces over /b/<key>, admission refuses a bad key / a wrong++// protocol / a duplicate slot, the coordinator records the session through Media's RTP ingest, a++// lost lease ends the sessions and a drain closes the listener and exits.++//++// It needs mediasoup and werift, which are NOT installed in this repo (installs are not available++// here): run it through the checkout that has them,++// NODE_PATH=<OpenVibe.Live>/node_modules npm test -- webrtc-e2e++// and it prints a skip line (not a failure) everywhere else.++const assert = require('assert');++const crypto = require('crypto');++const dgram = require('dgram');++const http = require('http');++const WebSocket = require('ws');++const { createWebrtc } = require('../workers/webrtc');++const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');++++let werift;++try {++ werift = require('werift');++ require.resolve('mediasoup');++} catch (err) {++ console.log('webrtc-e2e: skipped (mediasoup/werift not installed; run with NODE_PATH=<OpenVibe.Live>/node_modules)');++ process.exit(0);++}++++const t = suite('webrtc-e2e');++++function httpReq(port, method, path, { headers = {}, body } = {}) {++ return new Promise((resolve, reject) => {++ const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {++ let data = '';++ res.on('data', (c) => { data += c; });++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));++ });++ req.on('error', reject);++ if (body) req.write(body);++ req.end();++ });++}++++function wsOpen(url) {++ return new Promise((resolve, reject) => {++ const ws = new WebSocket(url);++ ws.inbox = [];++ ws.on('message', (d) => { try { ws.inbox.push(JSON.parse(d)); } catch { /* ignore */ } });++ ws.on('error', () => {});++ ws.once('open', () => resolve(ws));++ ws.once('unexpected-response', (req, res) => reject(new Error(`http ${res.statusCode}`)));++ ws.once('error', (err) => reject(err));++ });++}++const wsSend = (ws, obj) => ws.send(JSON.stringify(obj));++const wsWait = (ws, type, timeoutMs = 8000) => waitFor(() => ws.inbox.find((m) => m.type === type), { what: `ws ${type}`, timeoutMs });++++/** A fingerprinted DTLS fragment a client sends (format-valid; mediasoup does not handshake here). */++const fakeFingerprint = () => ({ fingerprints: [{ algorithm: 'sha-256', value: crypto.randomBytes(32).toString('hex').match(/.{2}/g).join(':').toUpperCase() }] });++++/** Feed a track with VP8 RTP at a steady rate, the way a browser/encoder would. */++function startDummyVideo(track, { fps = 30 } = {}) {++ const KEYFRAME = Buffer.from([0x10, 0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00]);++ const DELTA = Buffer.from([0x10, 0x11, 0x00, 0x00]);++ let seq = 0x4000;++ let timestamp = 0;++ let n = 0;++ const timer = setInterval(() => {++ const header = new werift.RtpHeader({ version: 2, payloadType: 96, sequenceNumber: seq++, timestamp, ssrc: 0x1a2b3c00, marker: true });++ track.writeRtp(new werift.RtpPacket(header, n % fps === 0 ? KEYFRAME : DELTA));++ timestamp += 3000;++ n++;++ }, 1000 / fps);++ timer.unref?.();++ return { stop: () => clearInterval(timer) };++}++++let rt;++let worker;++let exits;++let port;++let defA;++let keyA;++let sessionA;++let pub;++let udp;++let mediaPort;++const mediaCalls = [];++++t('setup: a real worker generation with mediasoup', async () => {++ port = await freePort();++ const rtcMin = 20000 + Math.floor(Math.random() * 20000);++ const egressMin = await freePort();++ rt = await runtime({++ env: {++ OPENRE_WEBRTC_PORT: String(port),++ OPENRE_WEBRTC_BIND: '127.0.0.1',++ OPENRE_WEBRTC_INTERNAL_PORT_MIN: String(egressMin),++ OPENRE_WEBRTC_INTERNAL_PORT_MAX: String(egressMin + 20),++ OPENRE_MEDIASOUP_MIN_PORT: String(rtcMin),++ OPENRE_MEDIASOUP_MAX_PORT: String(rtcMin + 60),++ MEDIASOUP_ANNOUNCED_IP: '127.0.0.1',++ OPENRE_WORKER_HEARTBEAT_MS: '300',++ OPENRE_DRAIN_MAX_MS: '900',++ OPENRE_RECORDING_START_DELAY_MS: '0',++ OPENRE_THUMBNAILS: 'off',++ },++ });++ exits = [];++ worker = createWebrtc({ rt, log: silent, exit: (code) => exits.push(code) });++ await worker.start();++ assert.strictEqual(worker.runtime.me.state, 'ready');++ assert.ok(worker.runtime.me.generation >= 1);++ assert.ok(worker.endpoints().egressPort > 0, 'the loopback egress endpoint is published');++});++++t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives its RTP', async () => {++ const created = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], recording_mode: 'vod', title: 'WebRTC A' });++ defA = created.definition;++ keyA = created.key.key;++++ const pc = new werift.RTCPeerConnection({});++ const track = new werift.MediaStreamTrack({ kind: 'video' });++ pc.addTransceiver(track, { direction: 'sendonly' });++ const offer = await pc.createOffer();++ await pc.setLocalDescription(offer);++++ const res = await httpReq(port, 'POST', `/whip/${keyA}`, { headers: { 'content-type': 'application/sdp' }, body: pc.localDescription.sdp });++ assert.strictEqual(res.status, 201, `WHIP answer (${res.status}): ${res.body.slice(0, 200)}`);++ assert.match(res.body, /a=recvonly/, 'the answer is a recvonly SDP');++ assert.match(res.body, /a=ice-lite/);++ assert.match(res.headers.location, /\/whip\/session\/[0-9a-f]{16,}$/, 'Location points at the session resource');++ assert.match(String(res.headers.etag), /^"/, 'an ETag is returned');++++ await pc.setRemoteDescription({ type: 'answer', sdp: res.body });++ pub = { pc, track, feed: startDummyVideo(track), resourceId: res.headers.location.split('/').pop() };++ await waitFor(() => ['connected', 'completed'].includes(pc.connectionState), { what: 'WHIP publisher ICE connected', timeoutMs: 20000 });++++ const sess = await waitFor(async () => { const s = (await rt.store.sessions.ofWorker(worker.runtime.me.id)).find((x) => x.definition_id === defA.id); return s && s.state === 'live' ? s : null; }, { what: 'session live after ICE', timeoutMs: 15000 });++ sessionA = sess.id;++ const pb = await rt.store.sessions.playback(sess);++ assert.ok(pb.webrtc.signaling_url.endsWith(`/w/${sessionA}`));++ assert.ok(!pb.webrtc.signaling_url.includes(keyA), 'the key is never in the viewer URL');++++ // A PlainRTP consumer (what restream / Media's RTP recorder / thumbnails use) must receive media.++ udp = dgram.createSocket('udp4');++ await new Promise((r) => udp.bind(0, '127.0.0.1', r));++ udp.packets = 0;++ udp.on('message', () => { udp.packets++; });++ const vport = udp.address().port;++ const egress = await httpReq(worker.endpoints().egressPort, 'GET', `/rtp/${sessionA}?vport=${vport}`);++ assert.strictEqual(egress.status, 200, `egress descriptor (${egress.status}): ${egress.body}`);++ const desc = JSON.parse(egress.body);++ assert.match(desc.sdp, /m=video \d+ RTP\/AVP/);++ assert.ok(desc.handle);++ udp.handle = desc.handle;++ await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });++++ const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });++ assert.strictEqual(patch.status, 204, 'trickle ICE is acknowledged');++});++++t('a WS viewer consumes by playback id and is counted in the heartbeat', async () => {++ const playback = await rt.store.sessions.playback(await rt.store.sessions.get(sessionA));++ const ws = await wsOpen(playback.webrtc.signaling_url);++ const welcome = await wsWait(ws, 'welcome');++ assert.strictEqual(welcome.role, 'viewer');++ wsSend(ws, { type: 'watch' });++ const ready = await wsWait(ws, 'sfu-viewer-ready');++ assert.ok(ready.rtpCapabilities.codecs.length > 0);++ const video = ready.producers.find((p) => p.kind === 'video');++ assert.ok(video, 'the WHIP video producer is advertised to the viewer');++++ wsSend(ws, { type: 'sfu-viewer-create-transport' });++ const transport = await wsWait(ws, 'sfu-viewer-transport-created');++ assert.ok(transport.iceCandidates.length > 0);++ wsSend(ws, { type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters: { role: 'client', ...fakeFingerprint() } });++ await wsWait(ws, 'sfu-viewer-transport-connected');++ wsSend(ws, { type: 'sfu-viewer-consume', transportId: transport.id, producerId: video.id, rtpCapabilities: ready.rtpCapabilities });++ const consumed = await wsWait(ws, 'sfu-viewer-consumed');++ assert.strictEqual(consumed.kind, 'video');++++ await worker.runtime.beat();++ const s = await waitFor(async () => { const x = await rt.store.sessions.get(sessionA); return x.viewers >= 1 ? x : null; }, { what: 'viewer count in the heartbeat', timeoutMs: 8000 });++ assert.ok(s.viewers >= 1, 'the consumer peer is counted');++ ws.close();++});++++t('the coordinator records the session through Media\'s RTP ingest over the real egress', async () => {++ mediaPort = await freePort();++ const media = {++ configured: true,++ appId: 'live',++ createVod: async () => { mediaCalls.push('createVod'); return { id: 'vod-e2e' }; },++ ingestRtpStart: async (vodId, codecs) => { mediaCalls.push(`ingestRtpStart:${codecs.video.codec}`); return { videoPort: mediaPort, audioPort: null }; },++ ingestRtpStop: async () => { mediaCalls.push('ingestRtpStop'); },++ finalizeVod: async () => { mediaCalls.push('finalizeVod'); },++ deleteVod: async () => { mediaCalls.push('deleteVod'); },++ };++ assert.strictEqual(await rt.store.recordings.ensureRequests(), 1);++ await rt.store.recordings.process(media);++ let rec = await rt.store.recordings.bySession(sessionA);++ assert.strictEqual(rec.state, 'recording');++ assert.ok(rec.rtp_handle, 'the worker egress handle is remembered');++ assert.strictEqual(rec.rtp_video_port, mediaPort);++ assert.ok(mediaCalls.includes('ingestRtpStart:VP8'), 'Media was asked for RTP ports with the codec');++});++++t('a browser broadcaster produces over /b/<key> and goes live', async () => {++ const created = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], title: 'WebRTC B' });++ const ws = await wsOpen(`ws://127.0.0.1:${port}/b/${created.key.key}`);++ const welcome = await wsWait(ws, 'welcome');++ assert.strictEqual(welcome.role, 'broadcaster');++ wsSend(ws, { type: 'sfu-get-capabilities' });++ const caps = await wsWait(ws, 'sfu-capabilities');++ assert.ok(caps.rtpCapabilities.codecs.length > 0);++ wsSend(ws, { type: 'sfu-create-transport' });++ const transport = await wsWait(ws, 'sfu-transport-created');++ wsSend(ws, { type: 'sfu-connect-transport', transportId: transport.id, dtlsParameters: { role: 'client', ...fakeFingerprint() } });++ await wsWait(ws, 'sfu-transport-connected');++ wsSend(ws, {++ type: 'sfu-produce', transportId: transport.id, kind: 'video',++ rtpParameters: { mid: '0', codecs: [{ mimeType: 'video/VP8', payloadType: 96, clockRate: 90000, parameters: {}, rtcpFeedback: [] }], headerExtensions: [], encodings: [{ ssrc: 24681012 }] },++ });++ const produced = await wsWait(ws, 'sfu-produced');++ assert.strictEqual(produced.kind, 'video');++ const live = await waitFor(async () => {++ const s = (await rt.store.sessions.ofWorker(worker.runtime.me.id)).find((x) => x.definition_id === created.definition.id);++ return s && s.state === 'live' ? s : null;++ }, { what: 'browser session live', timeoutMs: 8000 });++ assert.strictEqual(live.protocol, 'webrtc');++++ // Admission while this slot is already open is refused.++ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/${created.key.key}`), /http 403/, 'a duplicate broadcaster is refused');++ ws.close();++});++++t('admission refuses a bad key and a wrong-protocol key', async () => {++ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/ork_${'x'.repeat(43)}`), /http 403/, 'bad key refused');++ const wrong = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['rtmp'] });++ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/${wrong.key.key}`), /http 403/, 'rtmp-only slot refused on the WebRTC listener');++});++++t('DELETE ends the WHIP session', async () => {++ const res = await httpReq(port, 'DELETE', `/whip/session/${pub.resourceId}`);++ assert.strictEqual(res.status, 200);++ const s = await waitFor(async () => { const x = await rt.store.sessions.get(sessionA); return ['ended', 'failed'].includes(x.state) ? x : null; }, { what: 'WHIP session ended', timeoutMs: 8000 });++ assert.strictEqual(s.state, 'ended');++ pub.feed.stop();++ pub.pc.close();++});++++t('a lost lease ends the worker\'s sessions and exits', async () => {++ const port2 = await freePort();++ const rtcMin = 20000 + Math.floor(Math.random() * 20000);++ const egressMin = await freePort();++ const rt2 = await runtime({ env: {++ OPENRE_WEBRTC_PORT: String(port2), OPENRE_WEBRTC_BIND: '127.0.0.1',++ OPENRE_WEBRTC_INTERNAL_PORT_MIN: String(egressMin), OPENRE_WEBRTC_INTERNAL_PORT_MAX: String(egressMin + 20),++ OPENRE_MEDIASOUP_MIN_PORT: String(rtcMin), OPENRE_MEDIASOUP_MAX_PORT: String(rtcMin + 60),++ MEDIASOUP_ANNOUNCED_IP: '127.0.0.1', OPENRE_WORKER_HEARTBEAT_MS: '300', OPENRE_THUMBNAILS: 'off',++ } });++ const exits2 = [];++ const w2 = createWebrtc({ rt: rt2, log: silent, exit: (code) => exits2.push(code) });++ await w2.start();++ const created = await rt2.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });++ // Admit through the real signaling path so the session lives in the worker's own map.++ const ws = await wsOpen(`ws://127.0.0.1:${port2}/b/${created.key.key}`);++ await wsWait(ws, 'welcome');++ const sess = await waitFor(async () => (await rt2.store.sessions.ofWorker(w2.runtime.me.id))[0], { what: 'admitted session' });++ await rt2.store.workers.lose(w2.runtime.me.id, 'test');++ await w2.runtime.beat();++ await waitFor(() => exits2.length > 0, { what: 'lost worker exit', timeoutMs: 8000 });++ assert.strictEqual(exits2[0], 1, 'a lost worker exits non-zero');++ const s = await waitFor(async () => { const x = await rt2.store.sessions.get(sess.id); return ['ended', 'failed'].includes(x.state) ? x : null; }, { what: 'session ended after lease loss', timeoutMs: 8000 });++ assert.ok(s, 'the session is not left live');++ ws.close();++ await w2.close();++});++++t('a drain closes the listener, ends what is left and exits cleanly', async () => {++ await worker.drain('test');++ const refused = await waitFor(async () => {++ try { await httpReq(port, 'POST', `/whip/${keyA}`, { body: 'v=0' }); return false; } catch (err) { return /ECONNREFUSED/.test(err.message); }++ }, { what: 'the public listener closed to new sessions', timeoutMs: 5000 });++ assert.ok(refused, 'the public listener is closed to new sessions');++ await waitFor(() => exits.length > 0, { what: 'drained worker exit', timeoutMs: 10000 });++ assert.strictEqual(exits[0], 0, 'a drained generation exits 0');++ if (udp) { try { udp.close(); } catch { /* gone */ } }++});++++t('teardown', async () => {++ await sleep(50);++ if (worker) await worker.close().catch(() => {});++});++++t.run();nnew file mode 100644@@@ -0,0 +1,210 @@++'use strict';++// The WebRTC worker's store-level and pure behaviour, without mediasoup or PostgreSQL: config++// defaults, the WHIP SDP bridge, the { webrtc } playback descriptor, thumbnails (grab + Media++// object upload + openre.session.updated) and the coordinator's WebRTC RTP recording path, all++// against stubs. The real mediasoup end-to-end lives in webrtc-e2e.test.js (needs PostgreSQL).++const assert = require('assert');++const crypto = require('crypto');++const { load } = require('../server/config');++const sdp = require('../workers/webrtc/sdp');++const { webrtcArgs } = require('../workers/restream/ffmpeg-args');++const { createThumbnailer } = require('../workers/thumbnails');++const { createMediaClient } = require('../server/media-client');++const { runtime, tmpDir, suite, OWNER, outboxEnvelopes } = require('./helpers');++++const t = suite('webrtc');++++/** A minimal but real WHIP offer (VP8 + a DTLS fingerprint built at runtime, as a browser sends). */++function fixtureOffer() {++ const fp = crypto.randomBytes(32).toString('hex').match(/.{2}/g).join(':').toUpperCase();++ return [++ 'v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=-', 't=0 0',++ 'a=group:BUNDLE 0', 'a=msid-semantic: WMS',++ 'm=video 9 UDP/TLS/RTP/SAVPF 96', 'c=IN IP4 0.0.0.0', 'a=rtcp:9 IN IP4 0.0.0.0',++ 'a=ice-ufrag:abcd', 'a=ice-pwd:abcdefghijklmnopqrstuvwx', 'a=ice-options:trickle',++ `a=fingerprint:sha-256 ${fp}`, 'a=setup:actpass', 'a=mid:0', 'a=sendonly', 'a=rtcp-mux',++ 'a=rtpmap:96 VP8/90000', 'a=rtcp-fb:96 nack', 'a=rtcp-fb:96 nack pli', 'a=rtcp-fb:96 goog-remb',++ 'a=ssrc:11111111 cname:test', 'a=ssrc:11111111 msid:stream track', '',++ ].join('\r\n');++}++++t('config: the WebRTC ports never collide with Live and the RTC range is OpenRe\'s own', () => {++ const c = load({});++ assert.strictEqual(c.webrtc.port, 9936);++ assert.strictEqual(c.webrtc.media.minPort, 10200);++ assert.strictEqual(c.webrtc.media.maxPort, 10300);++ assert.notStrictEqual(c.webrtc.media.minPort, 10000, 'never Live\'s range');++ assert.strictEqual(c.webrtc.media.workers, 1);++ assert.deepStrictEqual(c.webrtc.media.mediaCodecs.map(x => x.mimeType), ['audio/opus', 'video/VP8', 'video/H264']);++ assert.strictEqual(load({ MEDIASOUP_ANNOUNCED_IP: '203.0.113.7' }).webrtc.media.announcedIp, '203.0.113.7');++ assert.strictEqual(load({ OPENRE_WEBRTC_STUN: 'stun:a:1,stun:b:2' }).webrtc.stunUrls.length, 2);++});++++t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {++ if (!sdp.available()) { console.log(' skip sdp-transform not installed'); return; }++ const offer = sdp.parse(fixtureOffer());++ assert.strictEqual(sdp.getDtlsSetupAttribute(offer), 'actpass');++ const dtls = sdp.extractDtlsParameters(offer);++ assert.strictEqual(dtls.role, 'client', 'actpass → the remote is the DTLS client');++ assert.strictEqual(dtls.fingerprints[0].algorithm, 'sha-256');++++ const caps = {++ codecs: [{ mimeType: 'video/VP8', clockRate: 90000, preferredPayloadType: 96 }],++ headerExtensions: [],++ };++ const rtp = sdp.extractRtpParameters(offer.media[0], caps, 0);++ assert.strictEqual(rtp.codecs[0].mimeType, 'video/VP8');++ assert.deepStrictEqual(rtp.encodings[0], { ssrc: 11111111 });++ assert.strictEqual(rtp.mid, '0');++++ const transport = { iceParameters: { usernameFragment: 'uf', password: 'pw' }, iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 1, ip: '203.0.113.7', port: 10200, type: 'host' }], dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }] } };++ const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRe' });++ const parsedAnswer = sdp.parse(answer);++ assert.strictEqual(parsedAnswer.media[0].direction, 'recvonly');++ assert.strictEqual(parsedAnswer.media[0].setup, 'passive');++ assert.strictEqual(parsedAnswer.icelite, 'ice-lite');++ assert.match(answer, /a=rtpmap:96 VP8\/90000/);++});++++t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {++ const rt = await runtime({ dir: tmpDir(), env: { MEDIASOUP_ANNOUNCED_IP: '203.0.113.7' } });++ const { definition, key } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });++ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });++ await rt.store.workers.ready(w.id);++ const a = await rt.store.sessions.admit({ definition, key, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });++ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });++++ const pb = await rt.store.sessions.playback(await rt.store.sessions.get(a.session.id));++ assert.deepStrictEqual(Object.keys(pb.webrtc).sort(), ['announced_ip', 'signaling_url']);++ assert.strictEqual(pb.webrtc.signaling_url, `ws://127.0.0.1:9936/w/${a.session.id}`);++ assert.ok(!pb.webrtc.signaling_url.includes(key.key), 'the key is never in the viewer URL');++ assert.strictEqual(pb.webrtc.announced_ip, '203.0.113.7');++ assert.strictEqual(pb.thumbnail_url, null);++++ // The ingest endpoints an encoder/UI needs.++ const ep = await rt.store.definitions.ingestEndpoints(await rt.store.definitions.get(definition.id));++ assert.strictEqual(ep.webrtc.whip_url, 'http://127.0.0.1:9936/whip');++ assert.strictEqual(ep.webrtc.signaling_url, 'ws://127.0.0.1:9936/b');++ rt.db.close();++});++++t('a thumbnail is recorded on the session and emits openre.session.updated once', async () => {++ const rt = await runtime({ dir: tmpDir() });++ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });++ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });++ await rt.store.workers.ready(w.id);++ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });++ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });++++ assert.strictEqual(await rt.store.sessions.setThumbnail(a.session.id, 'https://media.test/o/t1'), true);++ assert.strictEqual(await rt.store.sessions.setThumbnail(a.session.id, 'https://media.test/o/t1'), false, 'unchanged → no second event');++ const s = await rt.store.sessions.get(a.session.id);++ assert.strictEqual(s.thumbnail_url, 'https://media.test/o/t1');++ const ev = (await outboxEnvelopes(rt.db)).filter(e => e.event_type === 'openre.session.updated');++ assert.strictEqual(ev.length, 1);++ assert.strictEqual(ev[0].payload.thumbnail_url, 'https://media.test/o/t1');++ rt.db.close();++});++++t('a grabber captures a frame (ffmpeg stdout) and uploads it to Media as an object', async () => {++ const rt = await runtime({ dir: tmpDir() });++ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['rtmp'] });++ const w = await rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: {} });++ await rt.store.workers.ready(w.id);++ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'rtmp', worker: await rt.store.workers.get(w.id) });++ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });++++ const uploads = [];++ const media = { configured: true, uploadObject: async (o) => { uploads.push(o); return { id: 'o1', url: 'https://media.test/o/o1' }; } };++ // A fake ffmpeg: emits a JPEG-ish buffer on stdout and exits 0.++ const fakeSpawn = () => {++ const { EventEmitter } = require('events');++ const p = new EventEmitter();++ p.stdout = new EventEmitter(); p.stderr = new EventEmitter();++ p.kill = () => {};++ setImmediate(() => { p.stdout.emit('data', Buffer.alloc(4000, 1)); p.emit('close', 0); });++ return p;++ };++ const th = createThumbnailer({ config: rt.config, media, spawnImpl: fakeSpawn });++ assert.strictEqual(th.enabled, true);++ const buf = await th.capture(['-i', 'http://127.0.0.1/x.flv']);++ assert.ok(buf && buf.length === 4000);++ const url = await th.publish(rt.store, a.session.id, buf, { protocol: 'rtmp' });++ assert.strictEqual(url, 'https://media.test/o/o1');++ assert.strictEqual(uploads[0].namespace, 'live');++ assert.strictEqual(uploads[0].mimeType, 'image/jpeg');++ assert.strictEqual((await rt.store.sessions.get(a.session.id)).thumbnail_url, url);++ rt.db.close();++});++++t('media-client.uploadObject does init → PUT → complete', async () => {++ const calls = [];++ const fetchImpl = async (url, opts = {}) => {++ calls.push({ url: String(url), method: opts.method || 'GET' });++ if (String(url).endsWith('/objects')) return { ok: true, status: 201, text: async () => JSON.stringify({ id: 'o1', upload: { url: 'https://m/o1/content', complete_url: 'https://m/o1/complete', method: 'PUT' } }) };++ if (String(url).endsWith('/o1/content')) return { ok: true, status: 200, text: async () => '' };++ if (String(url).endsWith('/o1/complete')) return { ok: true, status: 200, text: async () => JSON.stringify({ object: { id: 'o1', public_url: 'https://media.test/o/o1' } }) };++ return { ok: false, status: 404, text: async () => '{}' };++ };++ const config = load({ MEDIA_URL: 'http://127.0.0.1:4100', MEDIA_API_KEY: 'k'.repeat(40), MEDIA_APP_ID: 'live' });++ const media = createMediaClient({ config, fetchImpl });++ const r = await media.uploadObject({ namespace: 'live', mimeType: 'image/jpeg', filename: 'x.jpg', bytes: Buffer.alloc(10, 1) });++ assert.strictEqual(r.url, 'https://media.test/o/o1');++ assert.deepStrictEqual(calls.map(c => c.method), ['POST', 'PUT', 'POST']);++ assert.ok(calls[0].url.includes('/api/v2/live/objects'));++});++++t('restream args: webrtcArgs reads the worker\'s SDP file and re-encodes', () => {++ const args = webrtcArgs('/tmp/x.sdp', 'rtmp://example/app/key', { videoBitrate: '3000k', maxrate: '3500k', bufsize: '3000k', audioBitrate: '128k', preset: 'veryfast', scale: '1280:720', fps: 30, gop: 60 }, { hasAudio: true });++ assert.strictEqual(args[args.indexOf('-i') + 1], '/tmp/x.sdp');++ assert.ok(args.includes('-protocol_whitelist'));++ assert.strictEqual(args[args.indexOf('-protocol_whitelist') + 1], 'file,rtp,udp');++ assert.ok(args.includes('libx264'));++ assert.strictEqual(args[args.length - 1], 'rtmp://example/app/key');++});++++t('the coordinator records a webrtc session through Media\'s RTP ingest and closes the egress', async () => {++ const egress = [];++ const fetchImpl = async (url, opts = {}) => {++ const u = String(url);++ egress.push(`${opts.method || 'GET'} ${u}`);++ if (u.endsWith('/describe')) return { ok: true, status: 200, text: async () => JSON.stringify({ video: { payloadType: 96, codec: 'VP8', clockRate: 90000 }, audio: null }) };++ if (u.includes('/rtp/') && (opts.method || 'GET') === 'GET') return { ok: true, status: 200, text: async () => JSON.stringify({ handle: 'h1', sdp: 'v=0\r\n', transports: ['t1'] }) };++ if (u.includes('/rtp/') && opts.method === 'DELETE') return { ok: true, status: 200, text: async () => JSON.stringify({ ok: true }) };++ return { ok: false, status: 404, text: async () => '{}' };++ };++ const rt = await runtime({ dir: tmpDir(), env: { OPENRE_RECORDING_START_DELAY_MS: '0' }, fetchImpl });++ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], recording_mode: 'vod' });++ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });++ await rt.store.workers.ready(w.id);++ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });++ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });++++ const calls = [];++ const media = {++ configured: true, appId: 'live',++ createVod: async () => { calls.push('createVod'); return { id: 'vod1' }; },++ ingestRtpStart: async (vodId, codecs) => { calls.push(`ingestRtpStart:${codecs.video.codec}`); return { videoPort: 12000, audioPort: 12002 }; },++ ingestRtpStop: async () => { calls.push('ingestRtpStop'); },++ finalizeVod: async () => { calls.push('finalizeVod'); },++ deleteVod: async () => {},++ };++ assert.strictEqual(await rt.store.recordings.ensureRequests(), 1);++ await rt.store.recordings.process(media);++ let rec = await rt.store.recordings.bySession(a.session.id);++ assert.strictEqual(rec.state, 'recording');++ assert.strictEqual(rec.rtp_handle, 'h1');++ assert.ok(egress.some(e => e.includes('/describe')));++ assert.ok(calls.includes('ingestRtpStart:VP8'), 'Media was asked for RTP ports with the codec');++++ await rt.store.sessions.finish(a.session.id, { reason: 'test', actor: 'w' });++ await rt.store.recordings.process(media);++ rec = await rt.store.recordings.bySession(a.session.id);++ assert.strictEqual(rec.state, 'finalized');++ assert.ok(calls.includes('ingestRtpStop'));++ assert.ok(calls.includes('finalizeVod'));++ assert.ok(egress.some(e => e.startsWith('DELETE')), 'the egress consumer was closed');++ rt.db.close();++});++++t.run();@@@ -29,6 +29,8 @@ const http = require('http');const crypto = require('crypto');const path = require('path');const { createWorkerRuntime } = require('./runtime');++const { createThumbnailer } = require('./thumbnails');++const { createMediaClient } = require('../server/media-client');const SESSION_ID_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;const INGEST_PATH_RE = /^\/([^/]+)\/(\d{1,5})\/(\d{1,5})\/?$/;@@@ -69,9 +71,10 @@ function listenInRange(server, min, max, skip = new Set()) {});}--function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }) {++function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code), fetchImpl, spawnImpl }) {const { store, config } = rt;const j = config.jsmpeg;++ const thumbnails = createThumbnailer({ config, log, media: createMediaClient({ config, ...(fetchImpl ? { fetchImpl } : {}) }), ...(spawnImpl ? { spawnImpl } : {}) });/** session id → { sessionId, definitionId, width, height, req, ended, live, endReason, viewers:Set, taps:Set } */const channels = new Map();// Ends in flight. The heartbeat calls exitWhenIdle() every beat; without this an ended session@@@ -136,7 +139,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }req.destroy();return;}-- ch = { sessionId: adm.sessionId, definitionId: adm.definitionId, req, width, height, live: false, ended: false, endReason: null, viewers: new Set(), taps: new Set() };++ ch = { sessionId: adm.sessionId, definitionId: adm.definitionId, req, width, height, live: false, ended: false, endReason: null, viewers: new Set(), taps: new Set(), thumbTimer: null };channels.set(ch.sessionId, ch);log.log(`[jsmpeg] session ${ch.sessionId} accepted for ${ch.definitionId} (key …${adm.hint}) at ${width}x${height}`);res.writeHead(200, { 'content-type': 'video/mp2t' });@@@ -165,7 +168,22 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }log.warn(`[jsmpeg] session ${ch.sessionId} could not go live (${t.code}); closing`);ch.endReason = 'coordinator_failed';try { if (ch.req && !ch.req.destroyed) ch.req.destroy(); } catch { /* gone */ }++ return;}++ startThumbnails(ch);++ }++++ /** Live thumbnails from this worker's loopback MPEG-TS tap (decision 4). Best effort. */++ function startThumbnails(ch) {++ if (!thumbnails.enabled || ch.thumbTimer || !tapServer) return;++ const tapUrl = `http://127.0.0.1:${tapServer.address().port}/tap/${ch.sessionId}.ts`;++ const grab = async () => {++ try { const buf = await thumbnails.capture(['-f', 'mpegts', '-i', tapUrl]); if (buf) await thumbnails.publish(store, ch.sessionId, buf, { protocol: 'jsmpeg', width: ch.width, height: ch.height }); }++ catch (err) { log.warn(`[jsmpeg] thumbnail ${ch.sessionId}: ${err.message}`); }++ };++ ch.thumbTimer = setInterval(() => grab().catch(() => {}), config.webrtc.thumbnails.intervalMs);++ ch.thumbTimer.unref?.();++ grab().catch(() => {});}function feed(ch, chunk) {@@@ -182,6 +200,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }if (ch.ended) return;ch.ended = true;channels.delete(ch.sessionId);++ if (ch.thumbTimer) { clearInterval(ch.thumbTimer); ch.thumbTimer = null; }pendingEnds++;try {for (const v of ch.viewers) { try { v.socket.end(wsFrame(OP_CLOSE, Buffer.alloc(0))); } catch { /* gone */ } }@@@ -62,11 +62,22 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit// A draining generation starts nothing new: an output assigned to it but not started yet// goes back to the coordinator for the newest generation.if (runtime.draining) { await store.outputs.unassign(row.id); seen.delete(row.id); continue; }-- const input = await inputUrlFor(session);-- if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }++ // The source URL for RTMP (loopback FLV) and JSMPEG (data tap); WebRTC has no pullable++ // URL — the runner asks the owning worker for a PlainRTP egress instead (egressBase).++ let input = null;++ let egressBase = null;++ if (session.protocol === 'webrtc') {++ const owner = session.worker_id ? await store.workers.get(session.worker_id) : null;++ const ep = (owner && owner.endpoints) || {};++ if (!ep.egressPort) { await store.outputs.report(row.id, { state: 'failed', last_error: 'the WebRTC worker for this session exposes no egress endpoint', ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }++ egressBase = `http://127.0.0.1:${ep.egressPort}`;++ } else {++ input = await inputUrlFor(session);++ if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }++ }// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;-- runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, source: session.protocol, store, config, log, spawnImpl, lookup, workerId: runtime.me.id });++ runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, source: session.protocol, sessionId: session.id, egressBase, store, config, log, spawnImpl, lookup, workerId: runtime.me.id });runners.set(row.id, runner);runner.start().catch((err) => runner.fail(err.message, { cooldown: false }));}@@@ -6,8 +6,8 @@* restreams correctly from Live restreams the same way from OpenRe.** RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,-- * read over HTTP) is re-encoded with encodingArgs(). The WebRTC (mediasoup PlainRTP → SDP) source-- * stays in Live until that ingest protocol moves.++ * read over HTTP) is re-encoded with encodingArgs(). A WebRTC source is mediasoup PlainRTP read++ * from an SDP file the owning worker wrote (rtpInputArgs/webrtcArgs), also re-encoded.*/const SRT_DEFAULT_LATENCY_MS = 120;@@@ -89,6 +89,32 @@ function withProgress(args) {: ['-progress', 'pipe:1', ...args];}++/**++ * ffmpeg input flags for a PlainRTP consumer described by an SDP file (Live's++ * RestreamManager._startWebrtcRestream): enough analyze/probe room and reorder space that a VP8++ * keyframe is never dropped as "old packet received too late" (which would leave the encoder with++ * no size and never go live). Kept byte-for-byte with Live's working flags.++ */++function rtpInputArgs(sdpPath) {++ return [++ '-protocol_whitelist', 'file,rtp,udp',++ '-thread_queue_size', '2048',++ '-analyzeduration', '10000000',++ '-probesize', '10000000',++ '-reorder_queue_size', '2048',++ '-use_wallclock_as_timestamps', '1',++ '-fflags', '+genpts+discardcorrupt+nobuffer+igndts',++ '-err_detect', 'ignore_err',++ '-avoid_negative_ts', 'make_zero',++ '-i', sdpPath,++ ];++}++++/** WebRTC (mediasoup PlainRTP → SDP file) → destination, re-encoded (VP8/Opus → H.264/AAC). */++function webrtcArgs(sdpPath, destUrl, preset, { hasAudio = true, overrides = {} } = {}) {++ return ['-hide_banner', '-loglevel', 'warning', ...rtpInputArgs(sdpPath), ...encodingArgs(preset, { hasAudio, overrides }), ...outputArgs(destUrl)];++}++function resolvePreset(destination) {const key = destination && destination.quality_preset || 'auto';if (key !== 'auto' && QUALITY_PRESETS[key]) return QUALITY_PRESETS[key];@@@ -164,5 +190,5 @@ function redactUrl(value) {module.exports = {QUALITY_PRESETS, PLATFORM_DEFAULT_PRESET, ENCODER_PRESETS, SRT_DEFAULT_LATENCY_MS,-- isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, jsmpegArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl, redactText,++ isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, jsmpegArgs, webrtcArgs, rtpInputArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl, redactText,};@@@ -8,17 +8,36 @@*/const { spawn } = require('child_process');const fs = require('fs');--const { rtmpCopyArgs, jsmpegArgs, withProgress, buildDestUrl, resolvePreset, customOverrides, friendlyError, redactUrl, redactText } = require('./ffmpeg-args');++const os = require('os');++const path = require('path');++const dgram = require('dgram');++const { rtmpCopyArgs, jsmpegArgs, webrtcArgs, withProgress, buildDestUrl, resolvePreset, customOverrides, friendlyError, redactUrl, redactText } = require('./ffmpeg-args');const { validateDestinationUrl, checkResolvedHost } = require('../../server/destination-url');++/** A free even UDP port (the WebRTC source's ffmpeg takes it and its +1 for RTCP). */++function freeUdpPort() {++ return new Promise((resolve, reject) => {++ const s = dgram.createSocket('udp4');++ s.once('error', reject);++ s.bind(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });++ });++}++async function allocateUdpPortPair() { const p = await freeUdpPort(); return p % 2 === 0 ? p : await freeUdpPort(); }++class OutputRunner {-- constructor({ outputId, destinationId, inputUrl, source = 'rtmp', store, config, log = console, spawnImpl = spawn, lookup, workerId = null }) {++ constructor({ outputId, destinationId, inputUrl, source = 'rtmp', sessionId = null, egressBase = null, store, config, log = console, spawnImpl = spawn, lookup, workerId = null, fetchImpl = globalThis.fetch }) {this.outputId = outputId;this.workerId = workerId;this.secrets = [];this.destinationId = destinationId;this.inputUrl = inputUrl;this.source = source;++ this.sessionId = sessionId;++ this.egressBase = egressBase;++ this.fetchImpl = fetchImpl;++ this.sdpPath = null;++ this.egressHandle = null;++ this.hasAudio = false;this.store = store;this.config = config;this.log = log;@@@ -71,13 +90,52 @@ class OutputRunner {if (!destUrl) return this.fail('destination has no usable URL or stream key', { cooldown: false });this.preset = resolvePreset(dest);this.overrides = customOverrides(dest);++ if (this.source === 'webrtc') {++ const prep = await this.prepareWebrtc();++ if (this.stopped) return;++ // No source yet (the producer has not appeared): retry with the usual backoff.++ if (!prep.ok) return this.scheduleRestart(0, prep.error);++ }this.spawn(destUrl);}++ /**++ * Open a PlainRTP egress on the worker that holds this session and write the SDP file ffmpeg++ * reads. The consumer (and its RTP stream to the ports ffmpeg will bind) is opened once and kept++ * across ffmpeg restarts, so only one egress consumer exists per output run.++ */++ async prepareWebrtc() {++ if (this.sdpPath && this.egressHandle) return { ok: true };++ if (!this.egressBase || !this.sessionId) return { ok: false, error: 'no WebRTC egress endpoint for this session' };++ let res;++ try {++ const port = await allocateUdpPortPair();++ const r = await this.fetchImpl(`${this.egressBase}/rtp/${this.sessionId}?vport=${port}&aport=${port + 2}`, { signal: AbortSignal.timeout(8000) });++ if (!r.ok) { const body = await r.json().catch(() => ({})); return { ok: false, error: `WebRTC source not ready (${body.error || r.status})` }; }++ res = await r.json();++ } catch (err) {++ return { ok: false, error: `WebRTC egress request failed: ${err.message}` };++ }++ const sdpPath = path.join(os.tmpdir(), `openre-restream-${this.outputId}.sdp`);++ try { fs.writeFileSync(sdpPath, res.sdp, 'utf8'); } catch (err) { return { ok: false, error: `SDP not writable: ${err.message}` }; }++ this.sdpPath = sdpPath;++ this.egressHandle = res.handle;++ this.hasAudio = Array.isArray(res.transports) && res.transports.length > 1;++ return { ok: true };++ }++++ async cleanupEgress() {++ if (this.egressHandle && this.egressBase) { try { await this.fetchImpl(`${this.egressBase}/rtp/${this.sessionId}/${this.egressHandle}`, { method: 'DELETE', signal: AbortSignal.timeout(5000) }); } catch { /* best effort */ } }++ this.egressHandle = null;++ if (this.sdpPath) { try { fs.unlinkSync(this.sdpPath); } catch { /* gone */ } this.sdpPath = null; }++ }++spawn(destUrl) {const args = withProgress(this.source === 'jsmpeg'? jsmpegArgs(this.inputUrl, destUrl, this.preset || resolvePreset({}), this.overrides ? { overrides: this.overrides } : {})-- : rtmpCopyArgs(this.inputUrl, destUrl));++ : this.source === 'webrtc'++ ? webrtcArgs(this.sdpPath, destUrl, this.preset || resolvePreset({}), { hasAudio: this.hasAudio, overrides: this.overrides || {} })++ : rtmpCopyArgs(this.inputUrl, destUrl));const rtmps = destUrl.startsWith('rtmps://');const bin = rtmps && this.o.ffmpegOpenSslPath && fs.existsSync(this.o.ffmpegOpenSslPath) ? this.o.ffmpegOpenSslPath : this.o.ffmpegPath;this.note('info', `starting ffmpeg → ${redactUrl(destUrl)}${bin !== this.o.ffmpegPath ? ' (openssl build)' : ''}`);@@@ -219,12 +277,14 @@ class OutputRunner {this.status = 'failed';this.note('error', `output failed: ${message}${cooldownMinutes ? ` (destination cooling down ${cooldownMinutes} min)` : ''}`);this.report({ state: 'failed', last_error: message, ended_at: Date.now(), next_restart_at: null, cooldown_minutes: cooldownMinutes });++ this.cleanupEgress().catch(() => {});}stop(reason = 'stopped') {if (this.stopped && !this.proc) return;this.stopped = true;this.clearTimers();++ this.cleanupEgress().catch(() => {});const proc = this.proc;this.proc = null;if (proc) {@@@ -30,6 +30,8 @@ const NodeFlvSession = require('node-media-server/src/node_flv_session');const context = require('node-media-server/src/node_core_ctx');const NmsLogger = require('node-media-server/src/node_core_logger');const { createWorkerRuntime } = require('./runtime');++const { createThumbnailer } = require('./thumbnails');++const { createMediaClient } = require('../server/media-client');const SESSION_PATH_RE = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})$/;@@@ -63,8 +65,9 @@ function admissionChain(admission, onAdmitted, onFailed, log) {return admission.then(onAdmitted, onFailed).catch((err) => log.error(`[rtmp] admission failed: ${err.stack || err}`));}--function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code) }) {++function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code), fetchImpl, spawnImpl }) {const { store, config } = rt;++ const thumbnails = createThumbnailer({ config, log, media: createMediaClient({ config, ...(fetchImpl ? { fetchImpl } : {}) }), ...(spawnImpl ? { spawnImpl } : {}) });NmsLogger.setLogType(1); // errors only: the library's info lines would print stream pathsconst nmsConfig = {logType: 1,@@@ -165,7 +168,7 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod// Rename before node-media-server registers the publisher (it reads publishStreamPath// right after this synchronous event): from here on the stream is /live/<session id>.s.publishStreamPath = `/live/${adm.sessionId}`;-- publishers.set(id, { sessionId: adm.sessionId, definitionId: adm.definitionId, endReason: null });++ publishers.set(id, { sessionId: adm.sessionId, definitionId: adm.definitionId, endReason: null, thumbTimer: null });log.log(`[rtmp] session ${adm.sessionId} accepted for ${adm.definitionId} (key …${adm.hint})`);});@@@ -177,12 +180,15 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod// The coordinator failed it meanwhile (lease) — the transport must not outlive its record.log.warn(`[rtmp] session ${p.sessionId} could not go live (${t.code}); closing`);await stopNms(id);++ return;}++ startThumbnails(p);});on('donePublish', async (id) => {const p = publishers.get(id);if (!p) return;++ if (p.thumbTimer) { clearInterval(p.thumbTimer); p.thumbTimer = null; }publishers.delete(id);await store.sessions.finish(p.sessionId, { reason: p.endReason || 'publisher_disconnected', actor: `worker:${runtime.me.id}` });log.log(`[rtmp] session ${p.sessionId} ended (${p.endReason || 'publisher_disconnected'})`);@@@ -213,6 +219,19 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod}}++ /** Live thumbnails from the loopback HTTP-FLV this worker serves (decision 4). Best effort. */++ function startThumbnails(p) {++ if (!thumbnails.enabled || p.thumbTimer || !endpoints.flvPort) return;++ const flvUrl = `http://127.0.0.1:${endpoints.flvPort}/live/${p.sessionId}.flv`;++ const grab = async () => {++ try { const buf = await thumbnails.capture(['-i', flvUrl]); if (buf) await thumbnails.publish(store, p.sessionId, buf, { protocol: 'rtmp' }); }++ catch (err) { log.warn(`[rtmp] thumbnail ${p.sessionId}: ${err.message}`); }++ };++ p.thumbTimer = setInterval(() => grab().catch(() => {}), config.webrtc.thumbnails.intervalMs);++ p.thumbTimer.unref?.();++ grab().catch(() => {});++ }++async function endSession(sessionId, reason) {for (const [id, p] of publishers) {if (p.sessionId === sessionId) { p.endReason = reason; await stopNms(id); return true; }nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * Live thumbnails for every ingest protocol (T4 decision 4). Each transport worker owns its source++ * and grabs one frame from it every interval, then uploads it to Media as an object under the `live`++ * namespace (grant media.object.upload ns live); the object's public URL becomes the session's++ * thumbnail_url and travels on the session state and its openre.session.* events. Live can no longer++ * read a local FLV/SFU/relay for OpenRe-owned sessions, so the frame has to come from here.++ *++ * The grabber is protocol-agnostic: the caller passes the ffmpeg *input* arguments for its source++ * (an RTMP/HTTP-FLV URL, the JSMPEG data tap, or an SDP file for a PlainRTP consumer) and this module++ * runs ffmpeg once, emitting one MJPEG frame on stdout. Best effort: a grab or an upload that fails is++ * logged and the session carries on.++ */++const { spawn } = require('child_process');++++function createThumbnailer({ config, log = console, spawnImpl = spawn, media }) {++ const cfg = config.webrtc.thumbnails;++ const enabled = Boolean(cfg.enabled && media && media.configured);++++ /** One keyframe frame from ffmpeg's input args, as a JPEG buffer (or null). */++ async function capture(inputArgs) {++ return await new Promise((resolve) => {++ const args = [++ '-hide_banner', '-loglevel', 'error', '-y',++ ...inputArgs,++ '-frames:v', '1', '-vf', `scale=${cfg.width}:-2`,++ '-f', 'image2pipe', '-vcodec', 'mjpeg', '-',++ ];++ let proc;++ try { proc = spawnImpl(config.outputs.ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch { resolve(null); return; }++ const chunks = [];++ proc.stdout.on('data', (d) => chunks.push(d));++ proc.stderr.on('data', () => {});++ const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* gone */ } }, 15000);++ timer.unref?.();++ const done = (ok) => { clearTimeout(timer); const buf = Buffer.concat(chunks); resolve(ok && buf.length > 2000 ? buf : null); };++ proc.on('error', () => done(false));++ proc.on('close', (code) => done(code === 0));++ });++ }++++ /** Upload a grabbed buffer to Media and record the URL on the session. Returns the URL or null. */++ async function publish(store, sessionId, buffer, metadata = {}) {++ if (!enabled || !buffer) return null;++ try {++ const r = await media.uploadObject({++ namespace: cfg.namespace,++ kind: 'image',++ visibility: 'public',++ mimeType: 'image/jpeg',++ filename: `openre-${sessionId}.jpg`,++ bytes: buffer,++ metadata: { openre_session_id: sessionId, ...metadata },++ });++ if (r && r.url) await store.sessions.setThumbnail(sessionId, r.url);++ return r ? r.url : null;++ } catch (err) {++ log.warn(`[thumbnails] ${sessionId}: upload failed: ${err.message}`);++ return null;++ }++ }++++ return { enabled, capture, publish };++}++++module.exports = { createThumbnailer };@@@ -2,12 +2,526 @@/*** openre-webrtc — the WebRTC transport worker (unit openre-webrtc@<release>.service).*-- * One worker owns WHIP ingest, the mediasoup producers/consumers and viewer signaling (T4 decision-- * 1: mediasoup is single-process, so ingest and consumption cannot be split). This replaces the-- * webrtc-ingest.js and sfu.js stubs.++ * One worker owns everything WebRTC (T4 decision 1: mediasoup is single-process, so WHIP ingest,++ * the SFU and viewer signaling cannot be split):*-- * Not ported yet: it registers a real generation and takes part in the drain protocol, but carries-- * nothing (see unported-transport.js). The next T4 job ports it.++ * public 0.0.0.0:OPENRE_WEBRTC_PORT (9936 until the WebRTC cutover; Live's WHIP/SFU live on its++ * app port 3000), SO_REUSEPORT so two generations listen during a drain:++ * POST/OPTIONS /whip/<key> WHIP ingest (RFC 9725): offer → answer++ * PATCH/DELETE /whip/session/<id> trickle ICE / end++ * WS upgrade /b/<key> browser broadcaster signaling (mediasoup-client)++ * WS upgrade /w/<session id> viewer signaling, by playback id (never the key)++ * internal 127.0.0.1:<egressPort> the RTP egress API the restream worker, Media's RTP recorder and++ * the thumbnail grabber use: GET /rtp/<session id>?vport&aport returns the SDP a pulling++ * ffmpeg reads (a PlainRTP consumer per producer sends RTP to those ports); DELETE++ * /rtp/<session id>/<transport id> closes one. Port from OPENRE_WEBRTC_INTERNAL_PORT_*.++ *++ * Admission is exactly the other workers': resolveIngestKey with protocol 'webrtc' (one open session++ * per definition), maxPublishersPerWorker, never while draining. A session goes live when media++ * flows — ICE connects for WHIP (Live's whip-handler rule), the first video producer for a browser++ * broadcaster — and ends on DELETE, ICE failure past its grace, broadcast WS close, when its last++ * producer closes, or on the coordinator's end/drain/lost signals.*/--if (require.main === module) require('./unported-transport').main('webrtc').catch((err) => { console.error(`[webrtc] failed to start: ${err && err.stack || err}`); process.exit(1); });--module.exports = require('./unported-transport');++const http = require('http');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const dgram = require('dgram');++const { createWorkerRuntime } = require('./runtime');++const { createSfu } = require('./webrtc/sfu');++const sdpTool = require('./webrtc/sdp');++const { createSignaling } = require('./webrtc/signaling');++const { createThumbnailer } = require('./thumbnails');++const { rtpInputArgs } = require('./restream/ffmpeg-args');++const { createMediaClient } = require('../server/media-client');++++const WHIP_KEY_RE = /^\/whip\/([^/]+)\/?$/;++const WHIP_SESSION_RE = /^\/whip\/session\/([0-9a-f]{16,})\/?$/;++const MAX_BODY = 1 * 1024 * 1024;++const ICE_GRACE_MS = 15000;++const PRODUCER_GONE_GRACE_MS = 2000;++++const WHIP_CORS = Object.freeze({++ 'Access-Control-Allow-Origin': '*',++ 'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',++ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',++ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag',++ 'Access-Control-Max-Age': '86400',++ 'Cross-Origin-Resource-Policy': 'cross-origin',++});++++/** Bind the first free loopback port in [min, max] (skipping `skip`). */++function listenInRange(server, min, max, skip = new Set()) {++ return new Promise((resolve, reject) => {++ let port = min;++ const tryNext = () => {++ while (skip.has(port)) port++;++ if (port > max) { reject(new Error(`no free loopback port in ${min}-${max}`)); return; }++ const onError = (err) => {++ server.removeListener('listening', onListening);++ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { port++; tryNext(); } else reject(err);++ };++ const onListening = () => { server.removeListener('error', onError); resolve(server.address().port); };++ server.once('error', onError);++ server.once('listening', onListening);++ server.listen(port, '127.0.0.1');++ };++ tryNext();++ });++}++++/** A free UDP port (best effort: bound then released, so ffmpeg can take it and its +1 for RTCP). */++function freeUdpPort() {++ return new Promise((resolve, reject) => {++ const s = dgram.createSocket('udp4');++ s.once('error', reject);++ s.bind(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });++ });++}++async function allocateUdpPortPair() {++ const p = await freeUdpPort();++ return p % 2 === 0 ? p : await freeUdpPort();++}++++function readBody(req, limit = MAX_BODY) {++ return new Promise((resolve, reject) => {++ let size = 0;++ const chunks = [];++ req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); });++ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));++ req.on('error', reject);++ });++}++++function createWebrtc({ rt, log = console, exit = (code) => process.exit(code), spawnImpl, fetchImpl = globalThis.fetch }) {++ const { store, config } = rt;++ const w = config.webrtc;++++ const sfu = createSfu({ config, log });++ const media = createMediaClient({ config, fetchImpl });++ const thumbnails = createThumbnailer({ config, log, ...(spawnImpl ? { spawnImpl } : {}), media });++++ /** sessionId → session record */++ const sessions = new Map();++ let pendingEnds = 0;++ let publicServer = null;++ let egressServer = null;++ let endpoints = null;++ let signaling = null;++++ const actor = () => `worker:${runtime.me && runtime.me.id}`;++ function safe(promise, what) {++ return Promise.resolve(promise).catch((err) => log.error(`[webrtc] ${what}: ${err && (err.stack || err.message) || err}`));++ }++++ // ── Admission ───────────────────────────────────────────────++ async function admit(key, mode) {++ if (runtime.draining || !runtime.me || runtime.me.state !== 'ready') return { error: 'generation not taking sessions' };++ if (sessions.size >= w.maxPublishersPerWorker) return { error: 'worker full' };++ const r = await store.definitions.resolveIngestKey(key, 'webrtc');++ if (r.error) return { error: r.error };++ const a = await store.sessions.admit({ definition: r.definition, key: r.key, protocol: 'webrtc', worker: runtime.me });++ if (a.error) return { error: a.error };++ const sess = {++ sessionId: a.session.id, definitionId: r.definition.id, hint: r.key.hint, mode,++ ended: false, endReason: null, live: false, createdAt: Date.now(),++ whip: new Map(), producers: new Set(), egress: new Map(),++ thumbTimer: null, timers: new Set(),++ };++ sessions.set(sess.sessionId, sess);++ return { sessionId: sess.sessionId, definitionId: sess.definitionId, hint: r.key.hint, peerId: `bc-${sess.sessionId}` };++ }++++ function timer(sess, fn, ms) {++ const t = setTimeout(() => { sess.timers.delete(t); fn(); }, ms);++ t.unref?.();++ sess.timers.add(t);++ return t;++ }++++ async function goLive(sess) {++ if (sess.live || sess.ended) return;++ sess.live = true;++ const t = await store.sessions.transition(sess.sessionId, 'live', { reason: 'media_flowing', actor: actor() });++ if (!t.ok) {++ log.warn(`[webrtc] session ${sess.sessionId} could not go live (${t.code}); closing`);++ sess.endReason = 'coordinator_failed';++ await endSession(sess.sessionId, 'coordinator_failed');++ }++ }++++ async function endSession(sessionId, reason) {++ const sess = sessions.get(sessionId);++ if (sess) { sess.endReason = reason; await endSessionRecord(sess, reason); return true; }++ pendingEnds++;++ try { await store.sessions.finish(sessionId, { reason, actor: actor() }); } finally { pendingEnds--; }++ if (runtime.draining) runtime.exitWhenIdle();++ return false;++ }++++ async function endSessionRecord(sess, reason) {++ if (sess.ended) return;++ sess.ended = true;++ sessions.delete(sess.sessionId);++ pendingEnds++;++ try {++ for (const t of sess.timers) clearTimeout(t);++ sess.timers.clear();++ if (sess.thumbTimer) clearInterval(sess.thumbTimer);++ for (const e of sess.egress.values()) { for (const tid of e.transportIds) { try { sfu.closePlainConsumer(sess.sessionId, tid); } catch { /* gone */ } } }++ sess.egress.clear();++ sfu.closeSession(sess.sessionId);++ await store.sessions.finish(sess.sessionId, { reason, actor: actor() });++ log.log(`[webrtc] session ${sess.sessionId} ended (${reason})`);++ } finally {++ pendingEnds--;++ }++ if (runtime.draining) runtime.exitWhenIdle();++ }++++ // ── WHIP ────────────────────────────────────────────────────++ function whipHeaders(req, sessionId, resourceId) {++ const host = req.headers.host || `${w.publicHost}:${w.publicPort}`;++ return {++ ...WHIP_CORS,++ Location: `https://${host}/whip/session/${resourceId}`,++ ETag: `"${resourceId}"`,++ };++ }++++ function sendWhipError(res, status, code, message) {++ res.writeHead(status, { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code });++ res.end(JSON.stringify({ error: message, error_code: code }));++ }++++ async function handleWhipPost(req, res, key) {++ const auth = req.headers.authorization;++ const bearer = auth && auth.startsWith('Bearer ') ? auth.slice(7).trim() : null;++ if (bearer && bearer !== key) return sendWhipError(res, 401, 'bearer_mismatch', 'Bearer token does not match stream key');++ let offerText;++ try { offerText = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Offer too large'); }++ if (req.headers['content-type'] && /json/.test(req.headers['content-type'])) {++ try { offerText = JSON.parse(offerText).sdp; } catch { return sendWhipError(res, 400, 'invalid_sdp', 'Invalid JSON body'); }++ }++ if (!offerText || typeof offerText !== 'string') return sendWhipError(res, 400, 'missing_sdp', 'Missing SDP offer in request body');++++ let offerSdp;++ try { offerSdp = sdpTool.parse(offerText); } catch { return sendWhipError(res, 400, 'invalid_sdp', 'Invalid SDP offer'); }++++ const adm = await admit(key, 'whip');++ if (adm.error) return sendWhipError(res, adm.error === 'worker full' ? 503 : 409, adm.error, `Publish refused: ${adm.error}`);++ const sess = sessions.get(adm.sessionId);++ const resourceId = crypto.randomBytes(16).toString('hex');++ const peerId = `whip-${resourceId}`;++++ let transportInfo;++ try {++ transportInfo = await sfu.createTransport(sess.sessionId, peerId, { iceConsentTimeout: 0 });++ } catch (err) {++ log.warn(`[webrtc] WHIP transport failed for ${sess.sessionId}: ${err.message}`);++ await endSessionRecord(sess, 'transport_creation_failed');++ return sendWhipError(res, 502, 'transport_creation_failed', 'WHIP transport creation failed');++ }++ const transport = transportInfo.transport;++++ try {++ await sfu.connectTransport(sess.sessionId, peerId, transportInfo.id, sdpTool.extractDtlsParameters(offerSdp));++ } catch (err) {++ log.warn(`[webrtc] WHIP DTLS failed for ${sess.sessionId}: ${err.message}`);++ await endSessionRecord(sess, 'dtls_negotiation_failed');++ return sendWhipError(res, 502, 'dtls_negotiation_failed', 'DTLS negotiation failed');++ }++++ const routerCaps = await sfu.getRouterCapabilities(sess.sessionId);++ const producersByKind = {};++ for (const [mediaIndex, m] of (offerSdp.media || []).entries()) {++ if (m.port === 0) continue;++ let rtpParams;++ try { rtpParams = sdpTool.extractRtpParameters(m, routerCaps, mediaIndex); } catch (err) {++ if (err.code === 'invalid_rtp_encoding') { await endSessionRecord(sess, 'invalid_rtp_encoding'); return sendWhipError(res, 400, 'invalid_rtp_encoding', `Invalid RTP encoding for ${m.type}`); }++ throw err;++ }++ if (!rtpParams || rtpParams.codecs.length === 0) continue;++ let produced;++ try { produced = await sfu.produce(sess.sessionId, peerId, transportInfo.id, m.type, rtpParams); } catch (err) {++ log.warn(`[webrtc] WHIP producer failed for ${sess.sessionId}: ${err.message}`);++ await endSessionRecord(sess, 'producer_creation_failed');++ return sendWhipError(res, 502, 'producer_creation_failed', 'Failed to create media producer');++ }++ producersByKind[m.type] = produced.producer;++ sess.producers.add(produced.id);++ }++ if (Object.keys(producersByKind).length === 0) {++ await endSessionRecord(sess, 'no_compatible_codecs');++ return sendWhipError(res, 406, 'no_compatible_codecs', 'No compatible codecs — router supports VP8, H264, Opus.');++ }++++ let answer;++ try { answer = sdpTool.buildSdpAnswer(transportInfo, offerSdp, producersByKind, { fallbackAddress: w.media.announcedIp || '127.0.0.1' }); } catch (err) {++ log.error(`[webrtc] SDP answer failed for ${sess.sessionId}: ${err.message}`);++ await endSessionRecord(sess, 'answer_generation_failed');++ return sendWhipError(res, 500, 'answer_generation_failed', 'Failed to generate SDP answer');++ }++++ sess.whip.set(resourceId, { peerId, transportId: transportInfo.id, iceReady: false, iceGraceTimer: null, iceFailTimer: null });++++ // The session goes live when media flows: ICE connected (Live's whip-handler rule).++ transport.on('icestatechange', (state) => {++ const entry = sess.whip.get(resourceId);++ if (!entry) return;++ if (state === 'failed') { clearTimeout(entry.iceFailTimer); safe(endSession(sess.sessionId, 'ice_failed'), 'ice failed end'); return; }++ if (state === 'disconnected') {++ clearTimeout(entry.iceGraceTimer);++ entry.iceGraceTimer = setTimeout(() => { if (sess.whip.has(resourceId) && !sess.live) safe(endSession(sess.sessionId, 'ice_disconnected'), 'ice grace end'); }, ICE_GRACE_MS);++ entry.iceGraceTimer.unref?.();++ return;++ }++ if (state === 'connected' || state === 'completed') {++ clearTimeout(entry.iceGraceTimer);++ if (!entry.iceReady) { entry.iceReady = true; safe(goLive(sess), 'go live'); }++ }++ });++ transport.on('dtlsstatechange', (state) => {++ if ((state === 'closed' || state === 'failed') && sess.whip.has(resourceId)) safe(endSession(sess.sessionId, 'dtls_closed'), 'dtls end');++ });++++ log.log(`[webrtc] WHIP session ${sess.sessionId} accepted for ${sess.definitionId} (key …${sess.hint}), ${Object.keys(producersByKind).length} producer(s)`);++ res.writeHead(201, { 'content-type': 'application/sdp', ...whipHeaders(req, sess.sessionId, resourceId) });++ res.end(answer);++ }++++ function handleWhipPatch(req, res, resourceId) {++ let found = null;++ for (const sess of sessions.values()) if (sess.whip.has(resourceId)) { found = sess; break; }++ if (!found) return sendWhipError(res, 404, 'session_not_found', 'Session not found');++ readBody(req).then((body) => { sdpTool.countCandidates(body); res.writeHead(204, WHIP_CORS); res.end(); }).catch(() => { res.writeHead(204, WHIP_CORS); res.end(); });++ }++++ async function handleWhipDelete(req, res, resourceId) {++ let found = null;++ for (const sess of sessions.values()) if (sess.whip.has(resourceId)) { found = sess; break; }++ if (found) await endSession(found.sessionId, 'whip_delete');++ res.writeHead(200, WHIP_CORS);++ res.end();++ }++++ // ── RTP egress (loopback) ───────────────────────────────────++ async function describeEgress(sess) {++ const caps = await sfu.getRouterCapabilities(sess.sessionId);++ const out = { video: null, audio: null };++ for (const kind of ['video', 'audio']) {++ const p = sfu.findProducerByKind(sess.sessionId, kind);++ if (!p) continue;++ const prod = sfu.getProducers(sess.sessionId).find(x => x.id === p.id);++ if (prod && prod.paused) continue;++ const routerCodec = caps.codecs.find(c => c.kind === kind && (kind !== 'video' || c.mimeType === 'video/VP8')) || caps.codecs.find(c => c.kind === kind);++ out[kind] = routerCodec ? {++ payloadType: routerCodec.preferredPayloadType,++ codec: (routerCodec.mimeType || `${kind}/unknown`).split('/')[1],++ clockRate: routerCodec.clockRate,++ ...(kind === 'audio' && routerCodec.channels ? { channels: routerCodec.channels } : {}),++ } : null;++ }++ return out;++ }++++ async function openEgress(sess, { vport, aport }) {++ const videoProducer = sfu.findProducerByKind(sess.sessionId, 'video');++ if (!videoProducer) return { error: 'no video producer yet' };++ const video = await sfu.createPlainConsumer(sess.sessionId, videoProducer.id, { remoteIp: '127.0.0.1', remotePort: vport, remoteRtcpPort: vport + 1 });++ const audioProducer = sfu.findProducerByKind(sess.sessionId, 'audio');++ let audio = null;++ if (audioProducer && aport) audio = await sfu.createPlainConsumer(sess.sessionId, audioProducer.id, { remoteIp: '127.0.0.1', remotePort: aport, remoteRtcpPort: aport + 1 });++ const transportIds = [video.transportId, ...(audio ? [audio.transportId] : [])];++ const sdp = sdpTool.buildEgressSdp({ video: { ...video, port: vport }, audio: audio ? { ...audio, port: aport } : null });++ return { sdp, transports: transportIds, video, audio };++ }++++ function handleEgressHttp(req, res) {++ const url = new URL(req.url, 'http://127.0.0.1');++ const json = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };++ const get = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/?$/.exec(url.pathname);++ const describe = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/describe$/.exec(url.pathname);++ const del = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/([a-f0-9]+)\/?$/.exec(url.pathname);++ safe((async () => {++ if (req.method === 'GET' && describe) {++ const sess = sessions.get(describe[1]);++ if (!sess) return json(404, { error: 'session_not_found' });++ return json(200, await describeEgress(sess));++ }++ if (req.method === 'GET' && get) {++ const sess = sessions.get(get[1]);++ if (!sess) return json(404, { error: 'session_not_found' });++ const vport = Number(url.searchParams.get('vport'));++ const aport = Number(url.searchParams.get('aport')) || 0;++ if (!Number.isInteger(vport) || vport <= 0) return json(400, { error: 'vport required' });++ try {++ const r = await openEgress(sess, { vport, aport });++ if (r.error) return json(404, { error: r.error });++ const handle = crypto.randomBytes(8).toString('hex');++ sess.egress.set(handle, { transportIds: r.transports, at: Date.now() });++ return json(200, { handle, sdp: r.sdp, transports: r.transports });++ } catch (err) { return json(502, { error: err.message }); }++ }++ if (req.method === 'DELETE' && del) {++ const sess = sessions.get(del[1]);++ if (!sess) return json(404, { error: 'session_not_found' });++ const handle = del[2];++ const e = sess.egress.get(handle);++ if (e) { for (const tid of e.transportIds) { try { sfu.closePlainConsumer(sess.sessionId, tid); } catch { /* gone */ } } sess.egress.delete(handle); }++ return json(200, { ok: true });++ }++ return json(404, { error: 'not_found' });++ })(), 'egress request');++ }++++ // ── Thumbnails ──────────────────────────────────────────────++ function startThumbnails(sess) {++ if (!thumbnails.enabled || sess.thumbTimer) return;++ const grab = async () => {++ if (sess.ended) return;++ try {++ const producer = sfu.findProducerByKind(sess.sessionId, 'video');++ if (!producer) return;++ const port = await allocateUdpPortPair();++ const video = await sfu.createPlainConsumer(sess.sessionId, producer.id, { remoteIp: '127.0.0.1', remotePort: port, remoteRtcpPort: port + 1 });++ const sdpPath = path.join(os.tmpdir(), `openre-thumb-${sess.sessionId}-${video.transportId}.sdp`);++ fs.writeFileSync(sdpPath, sdpTool.buildEgressSdp({ video: { ...video, port } }), 'utf8');++ let buffer = null;++ try { buffer = await thumbnails.capture(rtpInputArgs(sdpPath)); } finally {++ try { sfu.closePlainConsumer(sess.sessionId, video.transportId); } catch { /* gone */ }++ try { fs.unlinkSync(sdpPath); } catch { /* gone */ }++ }++ if (buffer) await thumbnails.publish(store, sess.sessionId, buffer, { protocol: 'webrtc' });++ } catch (err) { log.warn(`[webrtc] thumbnail ${sess.sessionId}: ${err.message}`); }++ };++ sess.thumbTimer = setInterval(() => safe(grab(), 'thumbnail'), w.thumbnails.intervalMs);++ sess.thumbTimer.unref?.();++ safe(grab(), 'thumbnail');++ }++++ // ── HTTP servers ────────────────────────────────────────────++ function handleRequest(req, res) {++ const url = new URL(req.url, 'http://127.0.0.1');++ if (url.pathname === '/whip' || url.pathname.startsWith('/whip/')) {++ if (req.method === 'OPTIONS') { res.writeHead(204, WHIP_CORS); return res.end(); }++ const kMatch = WHIP_KEY_RE.exec(url.pathname);++ const sMatch = WHIP_SESSION_RE.exec(url.pathname);++ if (req.method === 'POST' && kMatch) return safe(handleWhipPost(req, res, decodeURIComponent(kMatch[1])), 'whip post');++ if (req.method === 'PATCH' && sMatch) return handleWhipPatch(req, res, sMatch[1]);++ if (req.method === 'DELETE' && sMatch) return safe(handleWhipDelete(req, res, sMatch[1]), 'whip delete');++ res.writeHead(404, WHIP_CORS);++ return res.end();++ }++ res.writeHead(404);++ return res.end();++ }++++ async function closePublic() {++ if (!publicServer) return;++ publicServer.close();++ log.log('[webrtc] public listener closed; running sessions stay until they leave');++ }++++ function closeAll() {++ for (const sess of [...sessions.values()]) { try { sfu.closeSession(sess.sessionId); } catch { /* gone */ } }++ closePublic();++ if (egressServer) { egressServer.closeAllConnections?.(); egressServer.close(); egressServer = null; }++ if (signaling) signaling.closeAll();++ sfu.closeAll();++ const closing = [];++ if (publicServer) { publicServer.closeAllConnections?.(); closing.push(new Promise(r => publicServer.close(() => r()))); publicServer = null; }++ return Promise.all(closing);++ }++++ const runtime = createWorkerRuntime({++ rt, kind: 'webrtc', log, exit,++ hooks: {++ onDrain: () => closePublic(),++ onDrainDeadline: async () => { for (const s of [...sessions.values()]) await endSessionRecord(s, 'drain_deadline'); },++ onEndRequested: async (s) => await endSession(s.id, 'end_requested'),++ onLost: async () => { for (const s of [...sessions.values()]) await endSessionRecord(s, 'worker_lost'); return closeAll(); },++ onHeartbeat: async () => { for (const sess of sessions.values()) await store.sessions.setViewers(sess.sessionId, sfu.getViewerCount(sess.sessionId)); },++ activeCount: () => sessions.size + pendingEnds,++ onExit: () => closeAll(),++ },++ });++++ // A session with no producers left after having been live is over (its encoder disconnected).++ sfu.on('producer-removed', ({ sessionId }) => {++ const sess = sessions.get(sessionId);++ if (!sess || sess.ended) return;++ if (sfu.hasProducers(sessionId)) return;++ timer(sess, () => { if (!sess.ended && !sfu.hasProducers(sessionId) && sess.live) safe(endSession(sessionId, 'producer_closed'), 'producer gone end'); }, PRODUCER_GONE_GRACE_MS);++ });++ // A *browser* broadcaster goes live when its first video producer appears (Live's++ // broadcast-server rule). A WHIP publisher is different: media can be produced before ICE/DTLS++ // completes, so it only goes live when ICE connects (Live's whip-handler rule, below).++ sfu.on('producer-added', ({ sessionId, kind }) => {++ const sess = sessions.get(sessionId);++ if (sess && sess.mode === 'browser' && kind === 'video') safe(goLive(sess), 'go live');++ });++++ signaling = createSignaling({++ sfu,++ iceServers: w.stunUrls,++ log,++ admit: async (key) => {++ const r = await admit(key, 'browser');++ if (r.error) return { error: r.error };++ const sess = sessions.get(r.sessionId);++ startThumbnails(sess);++ return { sessionId: r.sessionId, peerId: r.peerId };++ },++ endSession: async (sessionId, reason) => await endSession(sessionId, reason),++ });++++ async function start() {++ if (!sfu.available) throw new Error('mediasoup is not installed (add mediasoup to package.json and npm install)');++ if (!sdpTool.available()) throw new Error('sdp-transform is not installed (add sdp-transform to package.json and npm install)');++ if (config.isProduction && !w.media.announcedIp) throw new Error('MEDIASOUP_ANNOUNCED_IP is required in production (the public address candidates are advertised with)');++ await sfu.init();++ sfu.setAnnouncedIp(w.media.announcedIp);++++ // Internal egress API first: it is how this generation is reached.++ egressServer = http.createServer((req, res) => handleEgressHttp(req, res));++ const egressPort = await listenInRange(egressServer, w.internalPortMin, w.internalPortMax);++ endpoints = { publicPort: w.port, publicPorts: [w.port], egressPort, announcedIp: w.media.announcedIp || null };++ await runtime.register(endpoints);++++ publicServer = http.createServer((req, res) => handleRequest(req, res));++ publicServer.on('upgrade', (req, socket, head) => { safe((async () => { if (await signaling.handleUpgrade(req, socket, head)) return; socket.destroy(); })(), 'upgrade'); });++ await new Promise((resolve, reject) => {++ publicServer.once('error', reject);++ publicServer.listen({ port: w.port, host: w.bindHost, reusePort: true }, () => resolve());++ });++ publicServer.on('error', (err) => log.error(`[webrtc] public listener ${w.port}: ${err.message}`));++ await runtime.ready();++ log.log(`[webrtc] generation ${runtime.me.generation} ready: whip+signaling ${w.bindHost}:${w.port}, egress 127.0.0.1:${egressPort}`);++ return endpoints;++ }++++ return {++ start, runtime, sfu, endpoints: () => endpoints,++ sessions: () => [...sessions.values()],++ drain: async (reason) => await runtime.drainNow(reason),++ close: closeAll,++ };++}++++module.exports = { createWebrtc, listenInRange };++++if (require.main === module) {++ (async () => {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ const { load, exitIfDrill } = require('../server/config');++ const { openRuntime } = require('../server/store');++ const config = load();++ exitIfDrill(config, 'openre-webrtc');++ const rt = await openRuntime({ config });++ const worker = createWebrtc({ rt });++ await worker.start();++ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig).catch((err) => console.error(`[webrtc] drain failed on ${sig}: ${err && (err.stack || err.message) || err}`)));++ })().catch((err) => { console.error(`[webrtc] failed to start: ${err && err.stack || err}`); process.exit(1); });++}nnew file mode 100644@@@ -0,0 +1,301 @@++'use strict';++/**++ * WHIP SDP <-> mediasoup bridging, ported from OpenVibe.Live server/streaming/whip-handler.js++ * (offer validation, DTLS parameter extraction, RTP parameter extraction against the router's++ * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRe++ * validates the offer, negotiates codecs and builds the answer, and nothing here touches a database.++ *++ * `sdp-transform` is required lazily so the module (and the whole worker) can be loaded — and its++ * string-only helpers tested — on a host where the dependency is not installed yet; `available()`++ * says whether parsing/building is possible at all, which is what the worker checks at start.++ */++let sdpTransform = null;++++function load() {++ if (!sdpTransform) sdpTransform = require('sdp-transform');++ return sdpTransform;++}++++function available() {++ try { load(); return true; } catch { return false; }++}++++function parse(sdp) {++ return load().parse(sdp);++}++++function write(obj) {++ return load().write(obj);++}++++/** The DTLS setup attribute of the offer (top-level or first media section that has one). */++function getDtlsSetupAttribute(sdpObj) {++ if (sdpObj.setup) return String(sdpObj.setup).toLowerCase();++ for (const media of sdpObj.media || []) if (media.setup) return String(media.setup).toLowerCase();++ return null;++}++++/** sha-256 is preferred over sha-1, exactly as Live's WHIP handler picks it. */++function selectDtlsFingerprint(fingerprints) {++ if (!Array.isArray(fingerprints) || fingerprints.length === 0) throw new Error('No DTLS fingerprints available');++ const normalized = fingerprints.map(fp => ({ algorithm: String(fp.algorithm || '').toLowerCase(), fingerprint: fp }));++ for (const algorithm of ['sha-256', 'sha-1']) {++ const match = normalized.find(item => item.algorithm === algorithm);++ if (match) return match.fingerprint;++ }++ return fingerprints[0];++}++++/** transport.connect()'s remote role: an offer with setup=passive means the remote side is the DTLS server. */++function extractDtlsParameters(sdpObj) {++ let fingerprint = sdpObj.fingerprint;++ const setup = getDtlsSetupAttribute(sdpObj);++ for (const media of sdpObj.media || []) if (!fingerprint && media.fingerprint) fingerprint = media.fingerprint;++ if (!fingerprint) throw new Error('No DTLS fingerprint in SDP');++ return {++ role: setup === 'passive' ? 'server' : 'client',++ fingerprints: [{ algorithm: fingerprint.type, value: fingerprint.hash }],++ };++}++++function h264ProfileIdc(profileLevelId) {++ const profile = String(profileLevelId || '').trim().toLowerCase();++ if (!/^[0-9a-f]+$/.test(profile) || profile.length < 2) return null;++ return profile.slice(0, 2);++}++++function normalizeH264ProfileLevelId(offeredParams, routerParams) {++ const off = h264ProfileIdc(offeredParams && offeredParams['profile-level-id']);++ const rc = h264ProfileIdc(routerParams && routerParams['profile-level-id']);++ if (!off || !rc || off !== rc) return false;++ if (routerParams && routerParams['profile-level-id']) offeredParams['profile-level-id'] = routerParams['profile-level-id'];++ return true;++}++++/**++ * RTP parameters for one media section, codecs matched against the router's capabilities. Throws++ * { code: 'invalid_rtp_encoding' } when the section names no usable encoding (Live does the same).++ */++function extractRtpParameters(media, routerCapabilities, mediaIndex = 0) {++ if (!media || !media.type) return null;++ const routerCodecs = routerCapabilities.codecs || [];++ const codecs = [];++ const headerExtensions = [];++ const encodings = [];++++ const rtpmaps = {};++ for (const rtp of media.rtp || []) {++ rtpmaps[rtp.payload] = {++ mimeType: `${media.type}/${rtp.codec}`,++ payloadType: rtp.payload,++ clockRate: rtp.rate,++ channels: rtp.encoding || undefined,++ parameters: {},++ rtcpFeedback: [],++ };++ }++ for (const fmtp of media.fmtp || []) {++ const target = rtpmaps[fmtp.payload];++ if (!target || !fmtp.config) continue;++ const params = {};++ for (const part of fmtp.config.split(';')) {++ const trimmed = part.trim();++ if (!trimmed) continue;++ const eqIdx = trimmed.indexOf('=');++ if (eqIdx > 0) {++ const key = trimmed.slice(0, eqIdx).trim();++ const val = trimmed.slice(eqIdx + 1).trim();++ if (key === 'profile-level-id' || key === 'sprop-parameter-sets' || key === 'level-asymmetry-allowed') params[key] = val;++ else params[key] = /^\d+$/.test(val) ? parseInt(val, 10) : val;++ }++ }++ target.parameters = params;++ }++ for (const fb of media.rtcpFb || []) {++ if (rtpmaps[fb.payload]) rtpmaps[fb.payload].rtcpFeedback.push({ type: fb.type, parameter: fb.subtype || '' });++ }++++ const payloads = (media.payloads || '').toString().split(' ').map(Number).filter(n => !isNaN(n));++ let primaryCodec = null;++ for (const pt of payloads) {++ const offered = rtpmaps[pt];++ if (!offered) continue;++ if (offered.mimeType.toLowerCase().endsWith('/rtx')) continue;++ const match = routerCodecs.find(rc => {++ if (rc.mimeType.toLowerCase() !== offered.mimeType.toLowerCase()) return false;++ if (rc.clockRate !== offered.clockRate) return false;++ if (offered.channels && rc.channels && offered.channels !== rc.channels) return false;++ if (rc.mimeType.toLowerCase() === 'video/h264') {++ const rcIdc = h264ProfileIdc(rc.parameters && rc.parameters['profile-level-id']);++ const offIdc = h264ProfileIdc(offered.parameters && offered.parameters['profile-level-id']);++ if (rcIdc && offIdc && rcIdc !== offIdc) return false;++ if (rcIdc && offIdc) normalizeH264ProfileLevelId(offered.parameters, rc.parameters || {});++ }++ return true;++ });++ if (match) { primaryCodec = offered; codecs.push(offered); break; }++ }++ if (!primaryCodec) return null;++++ for (const pt of payloads) {++ const offered = rtpmaps[pt];++ if (!offered) continue;++ if (offered.mimeType.toLowerCase() === `${media.type}/rtx` && offered.parameters && offered.parameters.apt === primaryCodec.payloadType) { codecs.push(offered); break; }++ }++++ for (const ext of media.ext || []) {++ const routerExt = (routerCapabilities.headerExtensions || []).find(re => re.uri === ext.uri && (re.kind === media.type || !re.kind));++ if (routerExt) headerExtensions.push({ uri: ext.uri, id: ext.value, encrypt: false, parameters: {} });++ }++++ const ssrcEntries = Array.isArray(media.ssrcs) ? media.ssrcs.filter(Boolean) : (media.ssrc ? [media.ssrc] : []);++ const normalizeSsrc = (v) => (v === undefined || v === null ? null : (Number.isFinite(Number(v)) ? Number(v) : String(v)));++ const mainSsrc = (ssrcEntries.find(s => s.attribute === 'cname') || ssrcEntries[0] || {}).id;++ let encoding = null;++ if (mainSsrc) {++ encoding = { ssrc: normalizeSsrc(mainSsrc) };++ const fidGroup = (media.ssrcGroups || []).find(g => g.semantics === 'FID' || g.semantics === 'fid');++ if (fidGroup && typeof fidGroup.ssrcs === 'string') {++ const parts = fidGroup.ssrcs.split(' ').map(s => s.trim()).filter(Boolean);++ if (parts.length >= 2 && String(normalizeSsrc(parts[0])) === String(encoding.ssrc)) encoding.rtx = { ssrc: normalizeSsrc(parts[1]) };++ }++ } else if (Array.isArray(media.rids) && media.rids.length > 0) {++ const ridValue = media.rids[0].id || media.rids[0];++ if (ridValue) encoding = { rid: String(ridValue) };++ } else if (media.rid) {++ encoding = { rid: String(media.rid) };++ }++ if (!encoding) {++ const error = new Error('No RTP encoding found in SDP media section');++ error.code = 'invalid_rtp_encoding';++ error.mediaType = media.type;++ error.mid = media.mid != null ? String(media.mid) : String(mediaIndex);++ throw error;++ }++ encodings.push(encoding);++++ return {++ codecs,++ headerExtensions,++ encodings,++ mid: media.mid != null ? String(media.mid) : String(mediaIndex),++ };++}++++/**++ * Build the SDP answer for a WHIP ingest (server recvonly). Only *accepted* m-sections go in the++ * BUNDLE group (RFC 8843 §7.3.3), so a browser publisher whose extra section (a data channel, a++ * codec the router lacks) is rejected is not refused by its own SDP parser.++ */++function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRe.Stream', fallbackAddress = '127.0.0.1' } = {}) {++ const { iceParameters, iceCandidates, dtlsParameters } = transport;++ const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);++ const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';++ const bundledMids = [];++ const serverAddress = (iceCandidates && iceCandidates[0] && iceCandidates[0].ip) || fallbackAddress;++++ const sdpObj = {++ version: 0,++ origin: { username: '-', sessionId: String(Date.now()), sessionVersion: 2, netType: 'IN', ipVer: 4, address: serverAddress },++ name: serverName,++ timing: { start: 0, stop: 0 },++ icelite: 'ice-lite',++ groups: [],++ msidSemantic: { semantic: 'WMS', token: '*' },++ media: [],++ };++++ for (const [mediaIndex, offerMedia] of (offerSdp.media || []).entries()) {++ const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mediaIndex);++ const producer = producersByKind[offerMedia.type];++ if (!producer) {++ sdpObj.media.push({++ type: offerMedia.type,++ port: 0,++ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',++ payloads: String(offerMedia.payloads || '0'),++ mid,++ direction: 'inactive',++ });++ continue;++ }++ bundledMids.push(mid);++ const answerMedia = {++ type: offerMedia.type,++ port: 9,++ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',++ payloads: '',++ connection: { ip: '0.0.0.0', version: 4 },++ mid,++ iceUfrag: iceParameters.usernameFragment,++ icePwd: iceParameters.password,++ fingerprint: { type: fingerprint.algorithm, hash: fingerprint.value },++ setup,++ direction: 'recvonly',++ rtcpMux: 'rtcp-mux',++ rtp: [],++ fmtp: [],++ rtcpFb: [],++ ext: [],++ candidates: (iceCandidates || []).map(c => {++ const cand = { foundation: c.foundation, component: 1, transport: c.protocol.toUpperCase(), priority: c.priority, ip: c.ip, port: c.port, type: c.type };++ if (c.tcpType) cand.tcptype = c.tcpType;++ return cand;++ }),++ };++ const pts = [];++ for (const codec of producer.rtpParameters.codecs) {++ pts.push(codec.payloadType);++ answerMedia.rtp.push({ payload: codec.payloadType, codec: codec.mimeType.split('/')[1], rate: codec.clockRate, encoding: codec.channels });++ if (codec.parameters && Object.keys(codec.parameters).length > 0) {++ answerMedia.fmtp.push({ payload: codec.payloadType, config: Object.entries(codec.parameters).map(([k, v]) => `${k}=${v}`).join(';') });++ }++ for (const fb of codec.rtcpFeedback || []) answerMedia.rtcpFb.push({ payload: codec.payloadType, type: fb.type, subtype: fb.parameter || undefined });++ }++ answerMedia.payloads = pts.join(' ');++ for (const ext of producer.rtpParameters.headerExtensions || []) answerMedia.ext.push({ value: ext.id, uri: ext.uri });++ sdpObj.media.push(answerMedia);++ }++++ if (bundledMids.length > 0) sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });++ return write(sdpObj);++}++++/** How many ICE candidates a trickle-PATCH body carries (used only for logging). */++function countCandidates(sdpText) {++ if (typeof sdpText !== 'string' || !sdpText.trim()) return 0;++ try {++ const parsed = parse(sdpText);++ return Array.isArray(parsed.media) ? parsed.media.reduce((sum, m) => sum + (Array.isArray(m.candidates) ? m.candidates.length : 0), 0) : 0;++ } catch {++ return 0;++ }++}++++/**++ * The SDP a pulling ffmpeg reads for a PlainRTP egress (restream, Media's RTP recorder, thumbnails),++ * ported from Live's RestreamManager._buildSdp. `port` is the local UDP port the *consumer* (ffmpeg)++ * listens on; the worker's PlainRTP consumer is already sending to it.++ */++function buildEgressSdp({ video, audio } = {}) {++ const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRe.Stream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];++ const section = (type, c, port) => {++ const pt = c.payloadType;++ const codecName = (c.mimeType || `${type}/VP8`).split('/')[1];++ if (type === 'audio') lines.push(`m=audio ${port} RTP/AVP ${pt}`);++ else lines.push(`m=video ${port} RTP/AVP ${pt}`);++ lines.push(`a=rtpmap:${pt} ${codecName}/${c.clockRate}${type === 'audio' ? `/${c.channels || 2}` : ''}`);++ if (c.ssrc) lines.push(`a=ssrc:${c.ssrc} cname:openre-${type}`);++ if (c.codecParameters && Object.keys(c.codecParameters).length) lines.push(`a=fmtp:${pt} ${Object.entries(c.codecParameters).map(([k, v]) => `${k}=${v}`).join(';')}`);++ lines.push('a=recvonly');++ };++ if (video) section('video', video, video.port);++ if (audio) section('audio', audio, audio.port);++ lines.push('');++ return lines.join('\r\n');++}++++module.exports = {++ available, parse, write,++ getDtlsSetupAttribute, selectDtlsFingerprint, extractDtlsParameters, extractRtpParameters, buildSdpAnswer, countCandidates, buildEgressSdp,++};nnew file mode 100644@@@ -0,0 +1,280 @@++'use strict';++/**++ * The mediasoup SFU, one Router per OpenRe session, ported from OpenVibe.Live++ * server/streaming/webrtc-sfu.js. The differences that matter:++ *++ * - a "room" is an OpenRe ingest session id (not Live's `stream-<id>`), so a router's lifetime++ * is exactly the session's;++ * - consumer/producer/transport bookkeeping is unchanged, so the viewer signaling and the++ * PlainRTP egress paths behave like Live's;++ * - PlainRTP consumers (restream, Media's RTP recording, thumbnails) send to an explicit remote++ * ip:port the caller chose, because on OpenRe the consumer side (ffmpeg) runs in another process.++ *++ * `mediasoup` is required lazily: the module loads (and its turn-free helpers run) on a host where++ * the native dependency is not installed, and `createSfu().available` is false there. The worker++ * refuses to start in that case rather than register a generation that can never take a session.++ */++const { EventEmitter } = require('events');++++function createSfu({ config, log = console }) {++ let mediasoup = null;++ try { mediasoup = require('mediasoup'); } catch { mediasoup = null; }++++ const media = config.webrtc.media;++ const emitter = new EventEmitter();++ const workers = [];++ let rr = 0;++ /** sessionId → { router, producers: Map<id,{producer,peerId,transportId}>, consumers: Map<id,{consumer,peerId}>, transports: Map<key,transport> } */++ const routers = new Map();++ let announcedIp = '';++++ async function init() {++ if (!mediasoup) throw new Error('mediasoup is not installed (add it to package.json and npm install)');++ if (workers.length) return workers[0];++ for (let i = 0; i < media.workers; i++) {++ const w = await mediasoup.createWorker({++ logLevel: 'warn',++ rtcMinPort: media.minPort,++ rtcMaxPort: media.maxPort,++ });++ w.on('died', () => {++ log.error(`[webrtc] mediasoup worker ${w.pid} died`);++ try { w.close(); } catch { /* already gone */ }++ emitter.emit('worker-died', w);++ });++ workers.push(w);++ }++ log.log(`[webrtc] mediasoup: ${workers.length} worker(s), RTC UDP ${media.minPort}-${media.maxPort}`);++ return workers[0];++ }++++ function setAnnouncedIp(ip) { announcedIp = ip || ''; }++++ function pickWorker() {++ if (!workers.length) throw new Error('SFU not initialised');++ return workers[rr++ % workers.length];++ }++++ async function getOrCreateRouter(sessionId) {++ if (routers.has(sessionId)) return routers.get(sessionId);++ if (!workers.length) throw new Error('SFU not initialised');++ const router = await pickWorker().createRouter({ mediaCodecs: media.mediaCodecs });++ const room = { router, producers: new Map(), consumers: new Map(), transports: new Map() };++ routers.set(sessionId, room);++ return room;++ }++++ function room(sessionId) { return routers.get(sessionId) || null; }++++ function hasRouter(sessionId) { return routers.has(sessionId); }++++ async function getRouterCapabilities(sessionId) {++ return (await getOrCreateRouter(sessionId)).router.rtpCapabilities;++ }++++ const listenIps = () => [{ ip: media.listenIp, ...(announcedIp ? { announcedIp } : {}) }];++++ async function createTransport(sessionId, peerId, options = {}) {++ const r = await getOrCreateRouter(sessionId);++ const transportOptions = {++ listenIps: listenIps(),++ enableUdp: true,++ enableTcp: true,++ preferUdp: true,++ initialAvailableOutgoingBitrate: 3000000,++ };++ // OBS/libdatachannel WHIP clients do not answer RFC 7675 consent requests; Live disables the++ // consent timeout for them the same way.++ if (typeof options.iceConsentTimeout === 'number') transportOptions.iceConsentTimeout = options.iceConsentTimeout;++ const transport = await r.router.createWebRtcTransport(transportOptions);++ transport.on('dtlsstatechange', (state) => {++ if (state === 'closed' || state === 'failed') { try { transport.close(); } catch { /* gone */ } }++ });++ r.transports.set(`${peerId}-${transport.id}`, transport);++ return {++ id: transport.id,++ iceParameters: transport.iceParameters,++ iceCandidates: transport.iceCandidates,++ dtlsParameters: transport.dtlsParameters,++ transport,++ };++ }++++ function lookupTransport(sessionId, peerId, transportId) {++ const r = room(sessionId);++ return r ? r.transports.get(`${peerId}-${transportId}`) : null;++ }++++ async function connectTransport(sessionId, peerId, transportId, dtlsParameters) {++ const transport = lookupTransport(sessionId, peerId, transportId);++ if (!transport) throw new Error('Transport not found');++ await transport.connect({ dtlsParameters });++ return transport;++ }++++ async function produce(sessionId, peerId, transportId, kind, rtpParameters) {++ const r = room(sessionId);++ if (!r) throw new Error('Router not found');++ const transport = r.transports.get(`${peerId}-${transportId}`);++ if (!transport) throw new Error('Transport not found');++ const producer = await transport.produce({ kind, rtpParameters });++ r.producers.set(producer.id, { producer, peerId, transportId });++ producer.on('transportclose', () => { r.producers.delete(producer.id); emitter.emit('producer-removed', { sessionId, producerId: producer.id, kind }); });++ emitter.emit('producer-added', { sessionId, producerId: producer.id, kind, peerId });++ return { id: producer.id, producer };++ }++++ async function consume(sessionId, peerId, transportId, producerId, rtpCapabilities) {++ const r = room(sessionId);++ if (!r) throw new Error('Router not found');++ if (!r.router.canConsume({ producerId, rtpCapabilities })) throw new Error('Cannot consume this producer');++ const transport = r.transports.get(`${peerId}-${transportId}`);++ if (!transport) throw new Error('Transport not found');++ const consumer = await transport.consume({ producerId, rtpCapabilities, paused: false });++ r.consumers.set(consumer.id, { consumer, peerId });++ consumer.on('transportclose', () => { r.consumers.delete(consumer.id); });++ consumer.on('producerclose', () => {++ try { consumer.close(); } catch { /* gone */ }++ r.consumers.delete(consumer.id);++ emitter.emit('consumer-closed', { sessionId, peerId, consumerId: consumer.id, kind: consumer.kind });++ });++ if (consumer.kind === 'video') {++ // A late viewer must not wait for the source's GOP boundary: nudge it a few times.++ for (const d of [0, 400, 1200]) setTimeout(() => { consumer.requestKeyFrame().catch(() => {}); }, d).unref?.();++ }++ return {++ id: consumer.id, producerId: consumer.producerId, kind: consumer.kind,++ rtpParameters: consumer.rtpParameters, consumer,++ };++ }++++ /**++ * A PlainRTP consumer that sends a producer's RTP to an explicit remote ip:port pair (the++ * restream worker's ffmpeg, Media's RTP ingest, or the thumbnail grabber). Returns the codec++ * facts the caller needs to write an SDP.++ */++ async function createPlainConsumer(sessionId, producerId, { remoteIp = '127.0.0.1', remotePort, remoteRtcpPort }) {++ const r = room(sessionId);++ if (!r) throw new Error('Router not found');++ const transport = await r.router.createPlainTransport({ listenIp: { ip: '127.0.0.1' }, rtcpMux: false, comedia: false });++ try {++ await transport.connect({ ip: remoteIp, port: remotePort, rtcpPort: remoteRtcpPort });++ } catch (err) {++ try { transport.close(); } catch { /* gone */ }++ throw err;++ }++ const consumer = await transport.consume({ producerId, rtpCapabilities: r.router.rtpCapabilities, paused: false });++ const key = `plain-${transport.id}`;++ r.transports.set(key, transport);++ r.consumers.set(consumer.id, { consumer, peerId: '__egress__' });++ const cleanup = () => { r.consumers.delete(consumer.id); r.transports.delete(key); };++ consumer.on('transportclose', cleanup);++ consumer.on('producerclose', () => {++ cleanup();++ try { transport.close(); } catch { /* gone */ }++ emitter.emit('consumer-closed', { sessionId, peerId: '__egress__', consumerId: consumer.id, kind: consumer.kind });++ });++ const codec = consumer.rtpParameters.codecs[0];++ const encoding = consumer.rtpParameters.encodings && consumer.rtpParameters.encodings[0];++ if (consumer.kind === 'video') {++ for (const d of [0, 500, 1500, 3000]) setTimeout(() => { consumer.requestKeyFrame().catch(() => {}); }, d).unref?.();++ }++ return {++ transportId: transport.id,++ consumerId: consumer.id,++ kind: consumer.kind,++ payloadType: codec && codec.payloadType,++ clockRate: codec && codec.clockRate,++ mimeType: codec && codec.mimeType,++ channels: codec && codec.channels,++ ssrc: encoding && encoding.ssrc,++ codecParameters: codec && codec.parameters,++ };++ }++++ function closePlainConsumer(sessionId, transportId) {++ const r = room(sessionId);++ if (!r) return;++ const key = `plain-${transportId}`;++ const transport = r.transports.get(key);++ if (!transport) return;++ for (const [id, entry] of r.consumers) {++ if (entry.peerId === '__egress__' && entry.consumer && entry.consumer.transportId === transportId) { try { entry.consumer.close(); } catch { /* gone */ } r.consumers.delete(id); }++ }++ try { transport.close(); } catch { /* gone */ }++ r.transports.delete(key);++ }++++ function findProducerByKind(sessionId, kind) {++ const r = room(sessionId);++ if (!r) return null;++ for (const [id, { producer, peerId }] of r.producers) if (producer.kind === kind && !producer.closed) return { id, peerId };++ return null;++ }++++ function hasProducers(sessionId) {++ const r = room(sessionId);++ return r ? [...r.producers.values()].some(({ producer }) => !producer.closed) : false;++ }++++ function getProducers(sessionId) {++ const r = room(sessionId);++ if (!r) return [];++ return [...r.producers.entries()]++ .filter(([, { producer }]) => !producer.closed)++ .map(([id, { producer, peerId, transportId }]) => {++ const transport = r.transports.get(`${peerId}-${transportId}`);++ return { id, peerId, kind: producer.kind, paused: producer.paused, dtlsState: transport ? transport.dtlsState : 'no-transport', iceState: transport ? transport.iceState : 'no-transport' };++ });++ }++++ /** Live counts unique consumer peers (viewers) in a room. */++ function getViewerCount(sessionId) {++ const r = room(sessionId);++ if (!r) return 0;++ const peers = new Set();++ for (const { peerId } of r.consumers.values()) if (peerId !== '__egress__') peers.add(peerId);++ return peers.size;++ }++++ function closePeer(sessionId, peerId) {++ const r = room(sessionId);++ if (!r) return;++ for (const [id, entry] of [...r.consumers]) {++ if (entry.peerId === peerId) { try { entry.consumer.close(); } catch { /* gone */ } r.consumers.delete(id); }++ }++ for (const [key, transport] of [...r.transports]) {++ if (key.startsWith(`${peerId}-`)) { try { transport.close(); } catch { /* gone */ } r.transports.delete(key); }++ }++ for (const [id, entry] of [...r.producers]) {++ if (entry.peerId === peerId) { try { entry.producer.close(); } catch { /* gone */ } r.producers.delete(id); }++ }++ }++++ function closeSession(sessionId) {++ const r = routers.get(sessionId);++ if (!r) return;++ for (const transport of r.transports.values()) { try { transport.close(); } catch { /* gone */ } }++ try { r.router.close(); } catch { /* gone */ }++ routers.delete(sessionId);++ }++++ function closeAll() {++ for (const sessionId of [...routers.keys()]) closeSession(sessionId);++ for (const w of workers.splice(0)) { try { w.close(); } catch { /* gone */ } }++ }++++ return {++ get available() { return Boolean(mediasoup); },++ on: (...args) => emitter.on(...args),++ off: (...args) => emitter.off(...args),++ init, setAnnouncedIp,++ getOrCreateRouter, hasRouter, getRouterCapabilities,++ createTransport, connectTransport, produce, consume,++ createPlainConsumer, closePlainConsumer,++ findProducerByKind, hasProducers, getProducers, getViewerCount,++ closePeer, closeSession, closeAll,++ };++}++++module.exports = { createSfu };nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++/**++ * WS signaling for WebRTC, modelled on the producer and viewer halves of OpenVibe.Live's++ * server/streaming/broadcast-server.js so Live's broadcast and watch pages can point their++ * mediasoup-client at OpenRe:++ *++ * ws://<host>:<port>/b/<ingest key> the broadcaster (Live's role: produce into the SFU)++ * ws://<host>:<port>/w/<session playback id> a viewer (consume; never carries the key)++ *++ * Broadcaster messages: sfu-get-capabilities | sfu-create-transport | sfu-connect-transport |++ * sfu-produce | sfu-stop-produce → the sfu-* replies Live's client expects.++ * Viewer messages: watch | sfu-viewer-create-transport | sfu-viewer-connect-transport |++ * sfu-viewer-consume → sfu-viewer-ready/…-consumed.++ *++ * Admission of the broadcaster uses the same resolveIngestKey/admit path as WHIP and RTMP (it is the++ * caller's `admit` callback). A viewer is keyed by the session's playback id and served by the++ * generation that holds the session; another generation drops the socket (same rule as JSMPEG).++ * Viewer counts are read from the SFU (active consumer peers) by the worker's heartbeat, not here.++ */++const { WebSocketServer } = require('ws');++++const BACKPRESSURE = 512 * 1024;++++function safeSend(ws, obj, log) {++ if (!ws || ws.readyState !== ws.OPEN) return;++ if (ws.bufferedAmount > BACKPRESSURE) { log.warn(`[webrtc] dropping ${obj && obj.type} — ws backpressure ${ws.bufferedAmount}`); return; }++ try { ws.send(JSON.stringify(obj)); } catch (err) { log.warn(`[webrtc] send ${obj && obj.type} failed: ${err.message}`); }++}++++function createSignaling({ sfu, iceServers, admit, endSession, log = console }) {++ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });++ /** ws → { role, sessionId, peerId } */++ const clients = new Map();++++ const ice = () => (iceServers || []).map(urls => ({ urls }));++++ function handleBroadcaster(ws, key) {++ ws.on('message', async (data) => {++ let msg;++ try { msg = JSON.parse(data); } catch { return; }++ const client = clients.get(ws);++ if (!client) return;++ const { sessionId, peerId } = client;++ try {++ switch (msg.type) {++ case 'sfu-get-capabilities':++ safeSend(ws, { type: 'sfu-capabilities', rtpCapabilities: await sfu.getRouterCapabilities(sessionId) }, log);++ break;++ case 'sfu-create-transport': {++ const t = await sfu.createTransport(sessionId, peerId);++ safeSend(ws, { type: 'sfu-transport-created', id: t.id, iceParameters: t.iceParameters, iceCandidates: t.iceCandidates, dtlsParameters: t.dtlsParameters, iceServers: ice() }, log);++ break;++ }++ case 'sfu-connect-transport':++ await sfu.connectTransport(sessionId, peerId, msg.transportId, msg.dtlsParameters);++ safeSend(ws, { type: 'sfu-transport-connected', transportId: msg.transportId }, log);++ break;++ case 'sfu-produce': {++ const r = await sfu.produce(sessionId, peerId, msg.transportId, msg.kind, msg.rtpParameters);++ safeSend(ws, { type: 'sfu-produced', id: r.id, kind: msg.kind }, log);++ break;++ }++ case 'sfu-stop-produce':++ sfu.closePeer(sessionId, peerId);++ break;++ default:++ break;++ }++ } catch (err) {++ log.warn(`[webrtc] signaling ${msg.type} failed for ${sessionId}: ${err.message}`);++ safeSend(ws, { type: 'sfu-error', error: err.message }, log);++ }++ });++ }++++ function handleViewer(ws, sessionId) {++ ws.on('message', async (data) => {++ let msg;++ try { msg = JSON.parse(data); } catch { return; }++ const client = clients.get(ws);++ if (!client) return;++ const peerId = client.peerId;++ try {++ switch (msg.type) {++ case 'watch': {++ if (!sfu.hasProducers(sessionId)) { safeSend(ws, { type: 'watch-queued', reason: 'awaiting_source' }, log); break; }++ safeSend(ws, {++ type: 'sfu-viewer-ready',++ rtpCapabilities: await sfu.getRouterCapabilities(sessionId),++ producers: sfu.getProducers(sessionId).filter(p => !p.paused && p.dtlsState === 'connected' && (p.iceState === 'connected' || p.iceState === 'completed')).map(p => ({ id: p.id, kind: p.kind })),++ }, log);++ break;++ }++ case 'sfu-viewer-create-transport': {++ const t = await sfu.createTransport(sessionId, peerId);++ safeSend(ws, { type: 'sfu-viewer-transport-created', id: t.id, iceParameters: t.iceParameters, iceCandidates: t.iceCandidates, dtlsParameters: t.dtlsParameters, iceServers: ice() }, log);++ break;++ }++ case 'sfu-viewer-connect-transport':++ await sfu.connectTransport(sessionId, peerId, msg.transportId, msg.dtlsParameters);++ safeSend(ws, { type: 'sfu-viewer-transport-connected', transportId: msg.transportId }, log);++ break;++ case 'sfu-viewer-consume': {++ const r = await sfu.consume(sessionId, peerId, msg.transportId, msg.producerId, msg.rtpCapabilities);++ safeSend(ws, { type: 'sfu-viewer-consumed', id: r.id, producerId: r.producerId, kind: r.kind, rtpParameters: r.rtpParameters }, log);++ break;++ }++ default:++ break;++ }++ } catch (err) {++ log.warn(`[webrtc] viewer signaling ${msg.type} failed for ${sessionId}: ${err.message}`);++ safeSend(ws, { type: 'sfu-viewer-error', error: err.message }, log);++ }++ });++ }++++ /** http server 'upgrade' handler. Returns true when the request was a signaling upgrade. */++ async function handleUpgrade(req, socket, head) {++ let pathname;++ try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; } catch { return false; }++ const bMatch = /^\/b\/([^/]+)\/?$/.exec(pathname);++ const wMatch = /^\/w\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/?$/.exec(pathname);++ if (!bMatch && !wMatch) return false;++++ if (bMatch) {++ // Admission runs before the handshake is completed, so a bad key never gets a socket.++ let adm;++ try { adm = await admit(decodeURIComponent(bMatch[1])); } catch (err) { adm = { error: err.message }; }++ if (!adm || adm.error) {++ log.log(`[webrtc] broadcast refused (${adm && adm.error}) from ${socket.remoteAddress || '?'}`);++ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRe-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');++ socket.destroy();++ return true;++ }++ wss.handleUpgrade(req, socket, head, (ws) => {++ const peerId = adm.peerId || `bc-${adm.sessionId}`;++ clients.set(ws, { role: 'broadcaster', sessionId: adm.sessionId, peerId });++ ws.on('close', () => { clients.delete(ws); sfu.closePeer(adm.sessionId, peerId); endSession?.(adm.sessionId, 'broadcaster_disconnected'); });++ ws.on('error', () => {});++ safeSend(ws, { type: 'welcome', peerId, role: 'broadcaster', sessionId: adm.sessionId, iceServers: ice() }, log);++ handleBroadcaster(ws, adm);++ });++ return true;++ }++++ // Viewer: only the generation that holds the session serves it.++ if (!sfu.hasRouter(wMatch[1])) { socket.destroy(); return true; }++ const peerId = `vw-${Math.random().toString(36).slice(2, 10)}`;++ wss.handleUpgrade(req, socket, head, (ws) => {++ clients.set(ws, { role: 'viewer', sessionId: wMatch[1], peerId });++ ws.on('close', () => { clients.delete(ws); sfu.closePeer(wMatch[1], peerId); });++ ws.on('error', () => {});++ safeSend(ws, { type: 'welcome', peerId, role: 'viewer', sessionId: wMatch[1], viewerCount: sfu.getViewerCount(wMatch[1]), iceServers: ice() }, log);++ handleViewer(ws, wMatch[1]);++ });++ return true;++ }++++ function closeAll() {++ for (const ws of clients.keys()) { try { ws.close(); } catch { /* gone */ } }++ clients.clear();++ try { wss.close(); } catch { /* gone */ }++ }++++ return { handleUpgrade, closeAll, clients };++}++++module.exports = { createSignaling };
@@ -44,9 +44,10 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
@@ -44,9 +44,10 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRe's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRe's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** |
| Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** |
| Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** |
| Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** |
-| Restream from a WebRTC source | same | encoder args ported (`encodingArgs`), source not (it needs that ingest) | not ported |
-| Recording | `server/streaming/recorder.js` + `server/media-client.js` | `server/store/recordings.js` + `server/media-client.js` — same Media API v1 calls (create VOD, `ingest/rtmp`, finalize, delete shell / clips-only) driven by the coordinator; Media pulls the loopback play URL | **ported (RTMP)**, tested against a stub Media that really pulls the URL |
-| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (werift + mediasoup; one worker owns all of it) | `workers/webrtc.js`: worker interface + coordinator registration only | **not ported** |
+| Restream from a WebRTC source | same | `webrtcArgs()`/`rtpInputArgs()` — re-encodes a mediasoup PlainRTP consumer the owning worker sends to ffmpeg's ports (SDP written by the worker's egress API) | **ported, tested with real ffmpeg + mediasoup** |
+| Recording | `server/streaming/recorder.js` + `server/media-client.js` | `server/store/recordings.js` + `server/media-client.js` — same Media API v1 calls (create VOD, `ingest/rtmp` for RTMP, `ingest/rtp/start|stop` for WebRTC, finalize, delete shell / clips-only) driven by the coordinator | **ported (RTMP + WebRTC)**, tested against a stub Media |
+| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (mediasoup; one worker owns all of it) | `workers/webrtc.js` + `workers/webrtc/{sfu,sdp,signaling}.js` — WHIP (RFC 9725) admission by key, one mediasoup Router per session, `/b/<key>` broadcaster + `/w/<id>` viewer signaling, loopback RTP/SDP egress for restream/recording/thumbnails; SO_REUSEPORT drain | **ported, tested with real mediasoup** |
+| Live thumbnails | `media-proxy/live-thumbs.js` | `workers/thumbnails.js` — the owning worker grabs a frame from its own source (FLV, MPEG-TS tap or a PlainRTP consumer) and uploads it to Media (object ns `live`); the session carries `thumbnail_url` | **ported, tested against a stub Media** |
| JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRe pushes to the stored key | **not ported** |
| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRe pushes to the stored key | **not ported** |
| Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |
| Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |
@@ -139,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
@@ -139,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
## Playback
## Playback
-`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. `webrtc` and `hls` are `null` (not produced). The session API also reports `viewers` (connected JSMPEG viewers, from the worker heartbeat). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch).
+`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRe's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.
## Recording
## Recording
@@ -165,6 +166,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -165,6 +166,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
| 19360–19399/tcp (loopback) | OpenRe per-generation RTMP play + HTTP-FLV | permanent |
| 19360–19399/tcp (loopback) | OpenRe per-generation RTMP play + HTTP-FLV | permanent |
| **9736/tcp** | OpenRe `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |
| **9736/tcp** | OpenRe `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |
| 19710–19749/tcp (loopback) | OpenRe per-generation JSMPEG MPEG-TS data tap | permanent |
| 19710–19749/tcp (loopback) | OpenRe per-generation JSMPEG MPEG-TS data tap | permanent |
+| **9936/tcp** | OpenRe `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |
+| **10200–10300/udp** | OpenRe `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |
+| 19810–19849/tcp (loopback) | OpenRe per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent |
| 4500/tcp (loopback) | `openre-api` | permanent |
| 4500/tcp (loopback) | `openre-api` | permanent |
The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.
The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.
@@ -209,7 +213,7 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
@@ -209,7 +213,7 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRe definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.
- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRe definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.
- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.
- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.
-Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` instead); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.
+Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.
## Migration
## Migration
@@ -244,9 +248,9 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
@@ -244,9 +248,9 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
`workers/jsmpeg.js` is ported (key-in-path admission, WebSocket viewers by playback id, the MPEG-TS data tap, generations/drain), and `jsmpegArgs()` restreams it. docs/cutover.md has the JSMPEG slot procedure (J-A once, then J-B1–B6 per slot): set `OPENRE_JSMPEG_BIND=0.0.0.0`, open 9736 at the provider edge, add `jsmpeg` to Live's `OPENRE_PROTOCOLS`, then the per-slot window/switch/rollback. JSMPEG has **no recording** (parity with Live): a jsmpeg definition with recording on fails the request explicitly.
`workers/jsmpeg.js` is ported (key-in-path admission, WebSocket viewers by playback id, the MPEG-TS data tap, generations/drain), and `jsmpegArgs()` restreams it. docs/cutover.md has the JSMPEG slot procedure (J-A once, then J-B1–B6 per slot): set `OPENRE_JSMPEG_BIND=0.0.0.0`, open 9736 at the provider edge, add `jsmpeg` to Live's `OPENRE_PROTOCOLS`, then the per-slot window/switch/rollback. JSMPEG has **no recording** (parity with Live): a jsmpeg definition with recording on fails the request explicitly.
-### WebRTC (not ready)
+### WebRTC (worker ready)
-One `workers/webrtc.js` will own WHIP ingest, the mediasoup producers/consumers and viewer signaling (decision 1: mediasoup is single-process, so ingest and consumption cannot be split). It needs its ported transport, a restream source for it, a `webrtc` playback descriptor, recording (RTP to Media) and `webrtc` in Live's `OPENRE_PROTOCOLS`. The same per-slot switch, window and rollback apply.
+`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` and `DELETE`, one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.
## Acceptance
## Acceptance
@@ -98,3 +98,42 @@ server {
@@ -98,3 +98,42 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
}
+
+## ═══════════════════════════════════════════════════════════════
+## REFERENCE (not installed): WHIP + WebRTC signaling over HTTPS at ingest.openre.stream.
+## Opus installs this (or an equivalent) at the WebRTC cutover. It terminates TLS for
+## `ingest.openre.stream` and proxies to the webrtc worker's public port (OPENRE_WEBRTC_PORT,
+## default 9936): WHIP POST/PATCH/DELETE under /whip/* and the WS signalling endpoints /b/* and
+## /w/*. The worker builds `Location: https://<host>/whip/session/<id>` from the request Host, so
+## set proxy_set_header Host. The certificate must cover ingest.openre.stream. The RTC media itself
+## does NOT go through nginx: it is UDP 10200-10300 straight to this host, so MEDIASOUP_ANNOUNCED_IP
+## must be the public address and 10200-10300/udp must be open at the provider edge (grey-cloud DNS;
+## Cloudflare's proxy cannot carry raw UDP WebRTC).
+##
+## server {
+## listen 443 ssl;
+## listen [::]:443 ssl;
+## http2 on;
+## server_name ingest.openre.stream;
+## ssl_certificate /etc/letsencrypt/live/ingest.openre.stream/fullchain.pem;
+## ssl_certificate_key /etc/letsencrypt/live/ingest.openre.stream/privkey.pem;
+## ssl_protocols TLSv1.2 TLSv1.3;
+##
+## client_max_body_size 256k;
+##
+## location / {
+## proxy_pass http://127.0.0.1:9936;
+## proxy_http_version 1.1;
+## proxy_set_header Host $host;
+## proxy_set_header X-Real-IP $remote_addr;
+## proxy_set_header X-Forwarded-For $remote_addr;
+## proxy_set_header X-Forwarded-Proto $scheme;
+## # WebSocket upgrade for /b/<key> and /w/<session id>
+## proxy_set_header Upgrade $http_upgrade;
+## proxy_set_header Connection $connection_upgrade;
+## proxy_read_timeout 12h;
+## send_timeout 12h;
+## }
+## }
+## (Add `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` in http{} once.)
+## ═══════════════════════════════════════════════════════════════
@@ -77,9 +77,9 @@ cmd_workers() {
@@ -77,9 +77,9 @@ cmd_workers() {
local sha=${1:-$(latest_release)}
local sha=${1:-$(latest_release)}
[ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
[ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
say "starting worker generation from release $sha (older generations drain by themselves)"
say "starting worker generation from release $sha (older generations drain by themselves)"
- systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service" "openre-jsmpeg@$sha.service"
+ systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service" "openre-jsmpeg@$sha.service" "openre-webrtc@$sha.service"
# Disable (not stop) older instances so a reboot does not bring them back; they exit on their own.
# Disable (not stop) older instances so a reboot does not bring them back; they exit on their own.
- for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' 'openre-jsmpeg@*' | awk '{print $1}'); do
+ for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' 'openre-jsmpeg@*' 'openre-webrtc@*' | awk '{print $1}'); do
case "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esac
case "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esac
done
done
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+[Unit]
+Description=OpenRe.Stream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i
+# One instance per release (instance name = the release directory). A worker deploy STARTS a new
+# instance; the coordinator then drains the older generation, which closes its public listener,
+# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
+# Nothing here depends on openre-api or openre-session-coordinator: an API deploy never restarts
+# this unit. Never `systemctl restart` it during a broadcast; see README "Deploying".
+# Needs MEDIASOUP_ANNOUNCED_IP (the public address) and UDP 10200-10300 open (see README "Port plan").
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openre.stream/releases/%i
+EnvironmentFile=/etc/openvibe/openre.env
+Environment=NODE_ENV=production
+Environment=OPENRE_RELEASE=%i
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node workers/webrtc.js
+# A crash restarts the instance as a new generation; a clean exit (drained) stays stopped.
+Restart=on-failure
+RestartSec=2
+# SIGTERM starts a drain; it does not drop sessions. Only the main process gets it.
+KillSignal=SIGTERM
+KillMode=mixed
+# An explicit stop (or host shutdown) waits this long for sessions to leave, then kills.
+TimeoutStopSec=30min
+LimitNOFILE=65536
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/openre
+# mediasoup spawns its own worker subprocesses and needs the RTC UDP range.
+LimitNPROC=infinity
+
+[Install]
+WantedBy=multi-user.target
@@ -373,7 +373,62 @@ Identical to B1–B6 with these substitutions:
@@ -373,7 +373,62 @@ Identical to B1–B6 with these substitutions:
broadcaster points ffmpeg back at Live's relay. JSMPEG has **no recording** (parity with Live): a
broadcaster points ffmpeg back at Live's relay. JSMPEG has **no recording** (parity with Live): a
jsmpeg definition with recording on fails the request with `recording is not available for jsmpeg`.
jsmpeg definition with recording on fails the request with `recording is not available for jsmpeg`.
-## Phase C: bookkeeping (AGENT, after A2)
+## WebRTC protocol cutover (WHIP + the SFU + viewer signaling)
+
+`workers/webrtc.js` carries the whole WebRTC stack (T4 decision 1: mediasoup is single-process, so
+ingest and consumption live together): WHIP ingest, the mediasoup producers/consumers, and the
+viewer signaling Live's broadcast/watch pages speak. Restream re-encodes a PlainRTP consumer's SDP,
+recording goes through Media's RTP ingest, and thumbnails are produced by the worker. This has to be
+done **once** (the worker generation and its ports), then per slot like every protocol.
+
+### W-A once (AGENT)
+
+- **Ports/firewall.** Open `9936/tcp` (WHIP + signaling; or put it behind nginx, see below) AND the
+ mediasoup RTC range `10200–10300/udp` (never Live's 10000–10100). `ingest.openre.stream` stays the
+ DNS-only record RTMP already uses; raw UDP cannot go through Cloudflare's proxy.
+- **Env (`/etc/openvibe/openre.env`).** `OPENRE_WEBRTC_BIND=0.0.0.0`, `MEDIASOUP_ANNOUNCED_IP=<this
+ host's public IP>` (required in production — the worker refuses to start without it),
+ `OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT/MAX_PORT=10200/10300`.
+ Each worker generation picks its own loopback egress port from `OPENRE_WEBRTC_INTERNAL_PORT_MIN..MAX`.
+- **nginx (Opus).** Install the reference `ingest.openre.stream` server block in
+ `deploy/nginx/openre.stream.conf` to terminate TLS in front of the worker's 9936, with the
+ `Upgrade`/`Connection` map for `/b/*` and `/w/*`. Cert must cover `ingest.openre.stream`.
+- **Deploy the worker.** `deploy.sh workers <sha>` now also starts `openre-webrtc@<sha>`; older
+ generations drain and exit by themselves.
+- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant
+ `media.object.upload` ns `live`); RTP recording uses `ingestRtpStart`/`ingestRtpStop` (Media ports
+ 12000–12199, already allocated by Media). Confirm both for OpenRe's token before switching a slot.
+- **Live:** add `webrtc` to `OPENRE_PROTOCOLS` in `server/openre/authority.js` **only when the
+ `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRe signaling
+ endpoints (`sfu-*` messages are unchanged; the page reads `playback.webrtc.signaling_url`).
+
+### W-B1–W-B6 per slot (OWNER + broadcaster + AGENT)
+
+- **B1 Preconditions:** the slot's `managed_streams.protocol` is `webrtc` (or `whip`), the broadcast
+ page sends `sfu-*` and not the legacy P2P messages, the slot is offline, `webrtc` is in Live's
+ `OPENRE_PROTOCOLS`, and `openre-webrtc` is ready.
+- **B2 Import:** `migrate-from-live.js` already imports `webrtc`/`whip` slots (protocols
+ `['webrtc']`).
+- **B3 Switch:** the same `PUT /api/admin/openre/managed/:id/ingest-authority {"authority":"openre"}`
+ (rotates Live's own key in-transaction). Live's WHIP handler must refuse the slot key once
+ `webrtc` is in `OPENRE_PROTOCOLS` (brief §4.5).
+- **B4 Broadcaster:** regenerate the key, then either
+ - **WHIP:** POST an SDP offer to `https://ingest.openre.stream/whip/<key>` (Bearer optional, must
+ match) → `201` + the answer + `Location: /whip/session/<id>`; trickle with `PATCH`, end with
+ `DELETE`. The session goes live when ICE connects.
+ - **Browser:** connect `ws(s)://ingest.openre.stream/b/<key>` and run the mediasoup-client
+ `sfu-get-capabilities → sfu-create-transport → sfu-connect-transport → sfu-produce` flow; the
+ session goes live on the first video producer.
+ - Viewers read the session playback descriptor (`webrtc.signaling_url`, keyed by playback id) and
+ run `watch → sfu-viewer-create-transport → sfu-viewer-connect-transport → sfu-viewer-consume`.
+- **B5/B6:** personal-key rotation unchanged; a test broadcast checks a viewer, a restream
+ destination (re-encoded from the PlainRTP/SDP egress), a recording (Media RTP ingest) and a
+ thumbnail (`thumbnail_url` on the session and in `openre.session.started|updated`).
+- **B-rollback:** `{"authority":"live"}` + `set-definition-state.js --state disabled`, then the
+ broadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the whole
+ protocol rolls back.
+
+
- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` into
- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` into
`/etc/openvibe/host.json`: `workerUnits`, and the drill block with `OPENRE_DRILL`. Then run
`/etc/openvibe/host.json`: `workerUnits`, and the drill block with `OPENRE_DRILL`. Then run
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
],
],
"eventsProduced": [
"eventsProduced": [
"openre.session.started",
"openre.session.started",
+ "openre.session.updated",
"openre.session.ended",
"openre.session.ended",
"openre.session.failed",
"openre.session.failed",
"openre.output.healthy",
"openre.output.healthy",
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: migrate
+-- WebRTC recording through Media's RTP ingest (T4 decision 2 / brief §4.4). A webrtc session is
+-- recorded by pointing a PlainRTP consumer at the RTP/RTCP port pair Media allocates; the
+-- coordinator remembers the worker egress handle and the two ports so it can close the consumer on
+-- finalize. Media's own RTP recorder needs codec info only at start, so nothing else changes here.
+-- Never edited after it runs; a further change is a new migration.
+ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_handle text COLLATE "C";
+ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_video_port bigint;
+ALTER TABLE recordings ADD COLUMN IF NOT EXISTS rtp_audio_port bigint;
@@ -14,14 +14,18 @@
@@ -14,14 +14,18 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
+ "mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sdp-transform": "^3.0.0",
+ "ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "werift": "^0.24.4"
},
},
"engines": {
"engines": {
"node": ">=22.12.0"
"node": ">=22.12.0"
@@ -34,12 +38,314 @@
@@ -34,12 +38,314 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@fidm/asn1": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
+ "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@fidm/x509": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",
+ "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/asn1": "^1.0.4",
+ "tweetnacl": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/@iovalkey/commands": {
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz",
+ "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "@peculiar/asn1-x509-attr": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz",
+ "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz",
+ "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz",
+ "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.10.0",
+ "@peculiar/asn1-pkcs8": "^2.10.0",
+ "@peculiar/asn1-rsa": "^2.10.0",
+ "@peculiar/asn1-schema": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz",
+ "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz",
+ "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.10.0",
+ "@peculiar/asn1-pfx": "^2.10.0",
+ "@peculiar/asn1-pkcs8": "^2.10.0",
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "@peculiar/asn1-x509-attr": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz",
+ "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz",
+ "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz",
+ "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz",
+ "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.10.0",
+ "@peculiar/asn1-x509": "^2.10.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+ "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.6.0",
+ "@peculiar/asn1-csr": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.0",
+ "@peculiar/asn1-pkcs9": "^2.6.0",
+ "@peculiar/asn1-rsa": "^2.6.0",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "pvtsutils": "^1.3.6",
+ "reflect-metadata": "^0.2.2",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@shinyoshiaki/binary-data": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz",
+ "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "generate-function": "^2.3.1",
+ "is-plain-object": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@types/dom-mediacapture-transform": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
+ "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/dom-webcodecs": "*"
+ }
+ },
+ "node_modules/@types/dom-webcodecs": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
+ "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -107,6 +413,21 @@
@@ -107,6 +413,21 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/base64-js": {
"node_modules/base64-js": {
"version": "1.5.1",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -357,6 +678,15 @@
@@ -357,6 +678,15 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/dateformat": {
"node_modules/dateformat": {
"version": "4.6.3",
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -436,6 +766,19 @@
@@ -436,6 +766,19 @@
"node": ">=8"
"node": ">=8"
}
}
},
},
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -617,6 +960,29 @@
@@ -617,6 +960,29 @@
],
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/file-uri-to-path": {
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -641,6 +1007,24 @@
@@ -641,6 +1007,24 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/forwarded": {
"node_modules/forwarded": {
"version": "0.2.0",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -674,6 +1058,16 @@
@@ -674,6 +1058,16 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
"node_modules/get-intrinsic": {
"node_modules/get-intrinsic": {
"version": "1.3.0",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -729,6 +1123,45 @@
@@ -729,6 +1123,45 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/h264-profile-level-id": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.3.tgz",
+ "integrity": "sha512-rPd5gpyWuIfEbBiHC/f9I4wdeCpAhg0aWdYgjMRdVTqhJT22C6VrWTi4hpAAgJad1Vv8BJDEvrzAQTW7CxzmJw==",
+ "license": "ISC",
+ "dependencies": {
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mediasoup"
+ }
+ },
+ "node_modules/h264-profile-level-id/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/h264-profile-level-id/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/has-flag": {
"node_modules/has-flag": {
"version": "4.0.0",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -890,6 +1323,36 @@
@@ -890,6 +1323,36 @@
"node": ">= 0.10"
"node": ">= 0.10"
}
}
},
},
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/json-schema-traverse": {
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -1023,6 +1486,82 @@
@@ -1023,6 +1486,82 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/mediabunny": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.61.0.tgz",
+ "integrity": "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "workspaces": [
+ ".",
+ "packages/*"
+ ],
+ "dependencies": {
+ "@types/dom-mediacapture-transform": "^0.1.11",
+ "@types/dom-webcodecs": "0.1.13"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ }
+ },
+ "node_modules/mediasoup": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.27.1.tgz",
+ "integrity": "sha512-unjG0ihJFZmCXxtS5ay6iPp+ov1Rg/BhJuP1gGfw4ElX/K5Z0it3PCC7rKecWMK2FgbQ52kKmDgZBPm/ewYO6A==",
+ "hasInstallScript": true,
+ "license": "ISC",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "flatbuffers": "^25.9.23",
+ "h264-profile-level-id": "^2.3.3",
+ "node-fetch": "^3.3.2",
+ "supports-color": "^11.0.0",
+ "tar": "^7.5.22"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mediasoup"
+ }
+ },
+ "node_modules/mediasoup/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mediasoup/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mediasoup/node_modules/supports-color": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-11.0.0.tgz",
+ "integrity": "sha512-/zyImLdxhdygBIaVX0xTlQhKaCDLCrm665aqHk8xqK/Pa6k61fL6gwQGQq1k31yNyz6x55PppQgF2DMyPQa5xw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/merge-descriptors": {
"node_modules/merge-descriptors": {
"version": "1.0.3",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -1095,6 +1634,27 @@
@@ -1095,6 +1634,27 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/mkdirp": {
"node_modules/mkdirp": {
"version": "2.1.6",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz",
@@ -1122,6 +1682,20 @@
@@ -1122,6 +1682,20 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
"node_modules/napi-build-utils": {
"node_modules/napi-build-utils": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -1149,6 +1723,44 @@
@@ -1149,6 +1723,44 @@
"node": ">=10"
"node": ">=10"
}
}
},
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/node-media-server": {
"node_modules/node-media-server": {
"version": "2.7.4",
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.7.4.tgz",
"resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.7.4.tgz",
@@ -1456,6 +2068,26 @@
@@ -1456,6 +2068,26 @@
"once": "^1.3.1"
"once": "^1.3.1"
}
}
},
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
+ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/qs": {
"node_modules/qs": {
"version": "6.16.0",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@ -1546,6 +2178,13 @@
@@ -1546,6 +2178,13 @@
"node": ">=4"
"node": ">=4"
}
}
},
},
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1581,6 +2220,15 @@
@@ -1581,6 +2220,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/sdp-transform": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
+ "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
+ "license": "MIT",
+ "bin": {
+ "sdp-verify": "checker.js"
+ }
+ },
"node_modules/semver": {
"node_modules/semver": {
"version": "7.8.5",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1815,6 +2463,22 @@
@@ -1815,6 +2463,22 @@
"node": ">=8"
"node": ">=8"
}
}
},
},
+ "node_modules/tar": {
+ "version": "7.5.22",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
+ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tar-fs": {
"node_modules/tar-fs": {
"version": "2.1.5",
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
@@ -1843,6 +2507,22 @@
@@ -1843,6 +2507,22 @@
"node": ">=6"
"node": ">=6"
}
}
},
},
+ "node_modules/tar/node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1852,6 +2532,13 @@
@@ -1852,6 +2532,13 @@
"node": ">=0.6"
"node": ">=0.6"
}
}
},
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/tsscmp": {
"node_modules/tsscmp": {
"version": "1.0.6",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
@@ -1861,6 +2548,26 @@
@@ -1861,6 +2548,26 @@
"node": ">=0.6.x"
"node": ">=0.6.x"
}
}
},
},
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/tunnel-agent": {
"node_modules/tunnel-agent": {
"version": "0.6.0",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -1873,6 +2580,13 @@
@@ -1873,6 +2580,13 @@
"node": "*"
"node": "*"
}
}
},
},
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+ "dev": true,
+ "license": "Unlicense"
+ },
"node_modules/type-is": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1919,6 +2633,86 @@
@@ -1919,6 +2633,86 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/werift": {
+ "version": "0.24.4",
+ "resolved": "https://registry.npmjs.org/werift/-/werift-0.24.4.tgz",
+ "integrity": "sha512-NoROZ11L/ZqAB5ombCB4VBuVNdIZfwI493zlxlIX7BlwfWRy55eDJdtWMC2VX35yBXFaWwA8NtYbmJ8qWtVk9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/x509": "^1.2.1",
+ "@noble/curves": "^1.8.1",
+ "@peculiar/x509": "^1.12.3",
+ "@shinyoshiaki/binary-data": "^0.6.1",
+ "buffer": "^6.0.3",
+ "debug": "4.4.0",
+ "mediabunny": "^1.45.2",
+ "multicast-dns": "^7.2.5",
+ "tweetnacl": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/werift/node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/werift/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/wrappy": {
"node_modules/wrappy": {
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1954,6 +2748,15 @@
@@ -1954,6 +2748,15 @@
"engines": {
"engines": {
"node": ">=0.4"
"node": ">=0.4"
}
}
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
}
}
}
}
}
}
@@ -30,16 +30,20 @@
@@ -30,16 +30,20 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
+ "mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sdp-transform": "^3.0.0",
+ "ws": "^8.18.0"
},
},
"engines": {
"engines": {
"node": ">=22.12.0"
"node": ">=22.12.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "werift": "^0.24.4"
}
}
}
}
@@ -84,6 +84,7 @@ function createV1Router({ rt, auth }) {
@@ -84,6 +84,7 @@ function createV1Router({ rt, auth }) {
failure_reason: s.failure_reason,
failure_reason: s.failure_reason,
media_info: s.media_info,
media_info: s.media_info,
viewers: s.viewers || 0,
viewers: s.viewers || 0,
+ thumbnail_url: s.thumbnail_url || null,
revision: s.revision,
revision: s.revision,
created_at: iso(s.created_at),
created_at: iso(s.created_at),
live_at: iso(s.live_at),
live_at: iso(s.live_at),
@@ -23,6 +23,7 @@ function load(env = process.env) {
@@ -23,6 +23,7 @@ function load(env = process.env) {
const port = int(env.PORT, 4500);
const port = int(env.PORT, 4500);
const rtmpPort = int(env.OPENRE_RTMP_PORT, 1936);
const rtmpPort = int(env.OPENRE_RTMP_PORT, 1936);
const jsmpegPort = int(env.OPENRE_JSMPEG_PORT, 9736);
const jsmpegPort = int(env.OPENRE_JSMPEG_PORT, 9736);
+ const webrtcPort = int(env.OPENRE_WEBRTC_PORT, 9936);
return {
return {
service: 'openre',
service: 'openre',
port,
port,
@@ -109,6 +110,57 @@ function load(env = process.env) {
@@ -109,6 +110,57 @@ function load(env = process.env) {
maxPublishersPerWorker: int(env.OPENRE_JSMPEG_MAX_PUBLISHERS, 64),
maxPublishersPerWorker: int(env.OPENRE_JSMPEG_MAX_PUBLISHERS, 64),
},
},
+ // ── Ingest (WebRTC: WHIP + the mediasoup SFU + viewer signaling) ──
+ // One worker kind 'webrtc' owns WHIP ingest, the mediasoup producers/consumers and viewer
+ // signaling (T4 decision 1: mediasoup is single-process, so ingest and consumption cannot be
+ // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRe listens on
+ // 10200–10300 (decision 3) so both stacks run side by side.
+ webrtc: {
+ // Public HTTP port: WHIP (POST/PATCH/DELETE /whip/…) and the WS signaling endpoints
+ // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRe brings its own.
+ // SO_REUSEPORT so two generations can listen during a drain.
+ port: webrtcPort,
+ bindHost: env.OPENRE_WEBRTC_BIND || '0.0.0.0',
+ publicHost: env.OPENRE_WEBRTC_PUBLIC_HOST || (isProduction ? 'ingest.openre.stream' : '127.0.0.1'),
+ publicPort: int(env.OPENRE_WEBRTC_PUBLIC_PORT, webrtcPort),
+ // Loopback HTTP API a generation serves RTP descriptors from: the restream worker, Media's
+ // recorder and the thumbnail grabber ask it for a PlainRTP feed of a session's producers.
+ // First free port from this range; published in the worker row.
+ internalPortMin: int(env.OPENRE_WEBRTC_INTERNAL_PORT_MIN, 19810),
+ internalPortMax: int(env.OPENRE_WEBRTC_INTERNAL_PORT_MAX, 19849),
+ maxPublishersPerWorker: int(env.OPENRE_WEBRTC_MAX_PUBLISHERS, 64),
+ // STUN/TURN servers handed to browser broadcasters and viewers (Live's SFU uses Google's
+ // public STUN; TURN_URL on Live is a later addition). Comma-separated list of URLs.
+ stunUrls: list(env.OPENRE_WEBRTC_STUN, ['stun:stun.l.google.com:19302']),
+ // mediasoup: one Worker per process (more only if OPENRE_MEDIASOUP_WORKERS says so), one
+ // Router per session. announcedIp (MEDIASOUP_ANNOUNCED_IP) is required in production: the
+ // public address candidates are advertised with, without which remote WebRTC clients cannot
+ // reach the worker. Never Live's 10000–10100 range.
+ media: {
+ listenIp: env.MEDIASOUP_LISTEN_IP || env.OPENRE_MEDIASOUP_LISTEN_IP || '0.0.0.0',
+ announcedIp: env.MEDIASOUP_ANNOUNCED_IP || env.OPENRE_MEDIASOUP_ANNOUNCED_IP || '',
+ minPort: int(env.OPENRE_MEDIASOUP_MIN_PORT, 10200),
+ maxPort: int(env.OPENRE_MEDIASOUP_MAX_PORT, 10300),
+ workers: Math.max(1, int(env.OPENRE_MEDIASOUP_WORKERS, 1)),
+ // Same codec set Live's SFU offers (server/streaming/webrtc-sfu.js + config.js).
+ mediaCodecs: [
+ { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
+ { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, parameters: { 'x-google-start-bitrate': 2500 } },
+ { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f', 'level-asymmetry-allowed': 1 } },
+ ],
+ },
+ // Live thumbnails come from the source the worker owns (decision 4): a frame grabbed every
+ // interval from a short-lived consumer, uploaded to Media and carried on the session state
+ // and its events as thumbnail_url.
+ thumbnails: {
+ enabled: env.OPENRE_THUMBNAILS !== 'off',
+ intervalMs: int(env.OPENRE_THUMBNAIL_INTERVAL_MS, 30000),
+ width: int(env.OPENRE_THUMBNAIL_WIDTH, 640),
+ // Namespace the object is uploaded under in Media (grant media.object.upload ns live).
+ namespace: env.OPENRE_THUMBNAIL_NS || 'live',
+ },
+ },
+
// ── Workers, leases, generations ────────────────────────
// ── Workers, leases, generations ────────────────────────
workers: {
workers: {
heartbeatMs: int(env.OPENRE_WORKER_HEARTBEAT_MS, 2000),
heartbeatMs: int(env.OPENRE_WORKER_HEARTBEAT_MS, 2000),
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
*
*
* Event types (source "openre"):
* Event types (source "openre"):
* openre.session.started a session reached live subject ingest_session
* openre.session.started a session reached live subject ingest_session
+ * openre.session.updated a live session's thumbnail_url changed subject ingest_session
* openre.session.ended a session that had been live ended subject ingest_session
* openre.session.ended a session that had been live ended subject ingest_session
* openre.session.failed a session failed (worker lost, error) subject ingest_session
* openre.session.failed a session failed (worker lost, error) subject ingest_session
* openre.output.healthy a restream output confirmed live subject output
* openre.output.healthy a restream output confirmed live subject output
@@ -24,6 +25,7 @@ const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
@@ -24,6 +25,7 @@ const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const TYPES = Object.freeze({
const TYPES = Object.freeze({
sessionStarted: 'openre.session.started',
sessionStarted: 'openre.session.started',
+ sessionUpdated: 'openre.session.updated',
sessionEnded: 'openre.session.ended',
sessionEnded: 'openre.session.ended',
sessionFailed: 'openre.session.failed',
sessionFailed: 'openre.session.failed',
outputHealthy: 'openre.output.healthy',
outputHealthy: 'openre.output.healthy',
@@ -58,8 +58,44 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
@@ -58,8 +58,44 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
appId: config.media.appId,
appId: config.media.appId,
createVod: async (fields) => await request('POST', '/vods', { body: fields }),
createVod: async (fields) => await request('POST', '/vods', { body: fields }),
ingestRtmp: async (vodId, rtmpUrl) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),
ingestRtmp: async (vodId, rtmpUrl) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),
+ /** Media RTP ingest: allocate a local RTP/RTCP port pair Media listens on (Live server/streaming/recorder.js). */
+ ingestRtpStart: async (vodId, { video, audio } = {}) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtp/start`, { body: { video, audio }, timeoutMs: 30000 }),
+ ingestRtpStop: async (vodId) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtp/stop`, { timeoutMs: 30000 }),
finalizeVod: async (vodId) => await request('POST', `/vods/${encodeURIComponent(vodId)}/finalize`, { timeoutMs: 30000 }),
finalizeVod: async (vodId) => await request('POST', `/vods/${encodeURIComponent(vodId)}/finalize`, { timeoutMs: 30000 }),
deleteVod: async (vodId) => await request('DELETE', `/vods/${encodeURIComponent(vodId)}`),
deleteVod: async (vodId) => await request('DELETE', `/vods/${encodeURIComponent(vodId)}`),
+ /**
+ * Upload bytes as a Media object (canonical object API /api/v2/:app/objects, Live/Media:
+ * docs/object-model.md): init → presigned PUT → complete. Used for live thumbnails under
+ * the `live` namespace (grant media.object.upload ns live). Returns the object's public URL.
+ */
+ uploadObject: async ({ namespace, kind = 'image', visibility = 'public', mimeType, filename, bytes, metadata } = {}) => {
+ const objectsBase = `${config.media.url}/api/v2/${encodeURIComponent(config.media.appId)}/objects`;
+ const headers = { Accept: 'application/json', ...(await authHeader()), 'Content-Type': 'application/json' };
+ const post = async (url, body) => {
+ const res = await fetchImpl(url, { method: 'POST', headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
+ const text = await res.text().catch(() => '');
+ let json = null;
+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }
+ if (!res.ok) throw new MediaError((json && (json.error || json.detail)) || `Media ${res.status} on POST ${url}`, res.status, json);
+ return json;
+ };
+ const init = await post(objectsBase, {
+ kind, namespace, visibility, mime_type: mimeType, filename,
+ size_bytes: bytes.length, metadata: { source: 'openre', ...(metadata || {}) },
+ });
+ const upload = init && init.upload;
+ if (!upload || !upload.url) throw new MediaError('Media returned no upload URL for the object', 0, init);
+ const put = await fetchImpl(upload.url, {
+ method: upload.method || 'PUT',
+ headers: { 'Content-Type': mimeType || 'application/octet-stream', 'Content-Length': String(bytes.length) },
+ body: bytes,
+ signal: AbortSignal.timeout(30000),
+ });
+ if (!put.ok) throw new MediaError(`Media object upload ${put.status}`, put.status, null);
+ const done = await post(upload.complete_url);
+ const object = (done && done.object) || done || {};
+ return { id: object.id || init.id, url: object.public_url || object.publicUrl || null };
+ },
};
};
}
}
@@ -219,7 +219,7 @@ function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}
@@ -219,7 +219,7 @@ function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}
if (s.reason && s.status !== 'imported') lines.push(`- ${s.status}: ${s.reason}`);
if (s.reason && s.status !== 'imported') lines.push(`- ${s.status}: ${s.reason}`);
if (s.status === 'imported') {
if (s.status === 'imported') {
if (s.protocol === 'webrtc' || s.protocol === 'whip') {
if (s.protocol === 'webrtc' || s.protocol === 'whip') {
- lines.push(`- Slot protocol is ${s.protocol}${s.streaming_method ? ` / ${s.streaming_method}` : ''}: the webrtc worker is not ported yet; leave this slot on Live until it ships.`);
+ lines.push(`- Slot protocol is ${s.protocol}${s.streaming_method ? ` / ${s.streaming_method}` : ''}: switch it after the WebRTC worker is live (docs/cutover.md, WebRTC; WHIP ingest + the mediasoup SFU + viewer signaling).`);
} else if (s.protocol === 'jsmpeg') {
} else if (s.protocol === 'jsmpeg') {
lines.push(`- Slot protocol is jsmpeg${s.streaming_method ? ` / ${s.streaming_method}` : ''}: switch it after the JSMPEG worker generation is running (docs/cutover.md, JSMPEG).`);
lines.push(`- Slot protocol is jsmpeg${s.streaming_method ? ` / ${s.streaming_method}` : ''}: switch it after the JSMPEG worker generation is running (docs/cutover.md, JSMPEG).`);
}
}
@@ -272,6 +272,19 @@ function createDefinitions({ db, config, events, clock }) {
@@ -272,6 +272,19 @@ function createDefinitions({ db, config, events, clock }) {
key_id: active ? active.id : null,
key_id: active ? active.id : null,
};
};
}
}
+ if (definition.protocols.includes('webrtc')) {
+ const w = config.webrtc;
+ const tls = w.publicPort === 443;
+ const host = `${w.publicHost}${tls ? '' : `:${w.publicPort}`}`;
+ out.webrtc = {
+ // WHIP ingest (POST the SDP offer to <whip_url>/<key>) and the browser broadcaster's
+ // signaling endpoint (join /b/<key> and run the mediasoup-client sfu-* flow).
+ whip_url: `${tls ? 'https' : 'http'}://${host}/whip`,
+ signaling_url: `${tls ? 'wss' : 'ws'}://${host}/b`,
+ key_hint: active ? active.hint : null,
+ key_id: active ? active.id : null,
+ };
+ }
return out;
return out;
}
}
@@ -30,13 +30,13 @@ function serializable(db) {
@@ -30,13 +30,13 @@ function serializable(db) {
});
});
}
}
-function createStore({ db, config, events, clock, box, log = console }) {
+function createStore({ db, config, events, clock, box, log = console, fetchImpl = globalThis.fetch }) {
db = serializable(db);
db = serializable(db);
const definitions = createDefinitions({ db, config, events, clock });
const definitions = createDefinitions({ db, config, events, clock });
const workers = createWorkers({ db, config, clock });
const workers = createWorkers({ db, config, clock });
const sessions = createSessions({ db, config, events, clock, definitions, workers });
const sessions = createSessions({ db, config, events, clock, definitions, workers });
const outputs = createOutputs({ db, config, events, clock, box, definitions, sessions });
const outputs = createOutputs({ db, config, events, clock, box, definitions, sessions });
- const recordings = createRecordings({ db, config, events, clock, definitions, sessions, log });
+ const recordings = createRecordings({ db, config, events, clock, definitions, sessions, workers, log, fetchImpl });
return { definitions, workers, sessions, outputs, recordings };
return { definitions, workers, sessions, outputs, recordings };
}
}
@@ -44,7 +44,7 @@ async function openRuntime({ config, clock = { now: () => Date.now() }, fetchImp
@@ -44,7 +44,7 @@ async function openRuntime({ config, clock = { now: () => Date.now() }, fetchImp
db = serializable(db || await openDb(config, { log }));
db = serializable(db || await openDb(config, { log }));
const events = createEvents({ config, db, fetchImpl, now: () => clock.now(), log });
const events = createEvents({ config, db, fetchImpl, now: () => clock.now(), log });
const box = createBox({ key: config.secretsKey, previous: config.secretsKeyPrevious });
const box = createBox({ key: config.secretsKey, previous: config.secretsKeyPrevious });
- const store = createStore({ db, config, events, clock, box, log });
+ const store = createStore({ db, config, events, clock, box, log, fetchImpl });
return { db, events, box, store, clock, config };
return { db, events, box, store, clock, config };
}
}
@@ -24,7 +24,7 @@ const MAX_ATTEMPTS = 10;
@@ -24,7 +24,7 @@ const MAX_ATTEMPTS = 10;
const DISK_RETRY_MS = 5 * 60 * 1000;
const DISK_RETRY_MS = 5 * 60 * 1000;
const DISK_MAX_ATTEMPTS = 12;
const DISK_MAX_ATTEMPTS = 12;
-function createRecordings({ db, config, events, clock, definitions, sessions, log = console }) {
+function createRecordings({ db, config, events, clock, definitions, sessions, workers, log = console, fetchImpl = globalThis.fetch }) {
const now = () => clock.now();
const now = () => clock.now();
const q = {
const q = {
get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
@@ -65,6 +65,113 @@ function createRecordings({ db, config, events, clock, definitions, sessions, lo
@@ -65,6 +65,113 @@ function createRecordings({ db, config, events, clock, definitions, sessions, lo
return r ? r.id : undefined;
return r ? r.id : undefined;
}
}
+ /** The egress base URL of the worker that owns this session (its RTP descriptor API), or null. */
+ async function egressBase(session) {
+ const owner = session.worker_id ? await workers.get(session.worker_id) : null;
+ const ep = (owner && owner.endpoints) || {};
+ return ep.egressPort ? `http://127.0.0.1:${ep.egressPort}` : null;
+ }
+
+ async function fetchJson(url, opts) {
+ const res = await fetchImpl(url, { ...opts, signal: AbortSignal.timeout(10000) });
+ const text = await res.text().catch(() => '');
+ let json = null;
+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }
+ if (!res.ok) { const e = new Error((json && json.error) || `egress ${res.status}`); e.status = res.status; throw e; }
+ return json;
+ }
+
+ /** Stop the worker sending RTP to Media: close the PlainRTP consumer it opened for us. */
+ async function closeWebrtcEgress(rec, session) {
+ if (!rec.rtp_handle) return;
+ const base = await egressBase(session);
+ if (base) { try { await fetchJson(`${base}/rtp/${session.id}/${rec.rtp_handle}`, { method: 'DELETE' }); } catch { /* best effort */ } }
+ await update(rec.id, { rtp_handle: null });
+ rec.rtp_handle = null;
+ }
+
+ /**
+ * Record a webrtc session through Media's RTP ingest (brief §4.4): Media allocates a video (and
+ * optional audio) RTP/RTCP port pair, the owning worker opens a PlainRTP consumer sending to it,
+ * and on finalize the consumer is closed and ingestRtpStop/finalize close the Media recording.
+ */
+ async function stepWebrtc(rec, media, session, definition) {
+ const ended = isTerminal(session.state) || session.state === 'ending';
+ const base = await egressBase(session);
+
+ if (rec.state === 'pending') {
+ if (ended) return await update(rec.id, { state: 'cancelled' });
+ if (!base) return await retryLater(rec, new Error('the WebRTC worker exposes no egress endpoint yet'));
+ let desc;
+ try { desc = await fetchJson(`${base}/rtp/${session.id}/describe`); } catch (err) { return await retryLater(rec, err); }
+ if (!desc.video) return await retryLater(rec, new Error('no WebRTC video producer yet'));
+ let vodId = rec.media_vod_id;
+ try {
+ if (!vodId) {
+ const liveUser = refOf(definition, 'live', 'user');
+ const liveSlot = refOf(definition, 'live', 'managed_stream');
+ const out = await media.createVod({
+ title: definition.title || 'Stream Recording',
+ user_id: liveUser != null ? Number(liveUser) : undefined,
+ managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,
+ clips_only: rec.mode === 'clips' || undefined,
+ visibility: definition.recording_visibility,
+ meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: 'webrtc', mode: rec.mode },
+ });
+ vodId = String(out && out.id);
+ rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
+ }
+ const ports = await media.ingestRtpStart(vodId, { video: desc.video, audio: desc.audio || undefined });
+ const videoPort = ports && (ports.videoPort || ports.video_port);
+ const audioPort = ports && (ports.audioPort || ports.audio_port);
+ if (!videoPort) throw new Error('Media returned no RTP ports for the recording');
+ const egress = await fetchJson(`${base}/rtp/${session.id}?vport=${videoPort}${audioPort ? `&aport=${audioPort}` : ''}`);
+ await update(rec.id, { rtp_handle: egress.handle || null, rtp_video_port: videoPort, rtp_audio_port: audioPort || null });
+ } catch (err) {
+ log.warn(`[recording] ${rec.id} (webrtc) request failed: ${err.message}`);
+ if (vodId) await media.deleteVod(vodId).catch(() => {});
+ await update(rec.id, { media_vod_id: null, state: 'pending' });
+ return await retryLater(await q.get.get(rec.id), err, { disk: /disk/i.test(String(err.message)) });
+ }
+ return await db.tx(async () => {
+ const r = await update(rec.id, { state: 'recording', attempts: 0, last_error: null, next_attempt_at: 0 });
+ await events.enqueue({
+ event_type: TYPES.recordingRequested,
+ actor: { type: 'service', id: 'openre' },
+ subject: { type: 'recording', id: rec.id, revision: 1 },
+ visibility: 'internal',
+ priority: 'important',
+ payload: {
+ recording_id: rec.id,
+ session_id: session.id,
+ stream_id: definition.id,
+ owner: { type: 'user', id: definition.owner_subject },
+ mode: rec.mode,
+ media: { app: config.media.appId, vod_id: r.media_vod_id },
+ external_refs: definition.external_refs,
+ },
+ });
+ return r;
+ });
+ }
+
+ if (rec.state === 'requested') return await update(rec.id, { state: 'pending' });
+
+ if (rec.state === 'recording' && ended) rec = await update(rec.id, { state: 'finalizing' });
+ if (rec.state === 'finalizing') {
+ await closeWebrtcEgress(rec, session);
+ try { await media.ingestRtpStop(rec.media_vod_id); } catch (err) { if (err.status !== 409) return await retryLater(rec, err); }
+ try {
+ await media.finalizeVod(rec.media_vod_id);
+ if (rec.mode === 'clips') await media.deleteVod(rec.media_vod_id).catch(() => {});
+ } catch (err) {
+ if (err.status !== 409) return await retryLater(rec, err);
+ }
+ return await update(rec.id, { state: 'finalized', last_error: null });
+ }
+ return rec;
+ }
+
async function step(rec, media) {
async function step(rec, media) {
const session = await sessions.get(rec.session_id);
const session = await sessions.get(rec.session_id);
if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });
if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });
@@ -94,6 +201,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, lo
@@ -94,6 +201,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, lo
});
});
}
}
+ if (session.protocol === 'webrtc') return await stepWebrtc(rec, media, session, definition);
+
if (rec.state === 'pending') {
if (rec.state === 'pending') {
if (ended) return await update(rec.id, { state: 'cancelled' });
if (ended) return await update(rec.id, { state: 'cancelled' });
const pb = await sessions.playback(session);
const pb = await sessions.playback(session);
@@ -31,6 +31,9 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -31,6 +31,9 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
// Viewer counts live inside media_info so no column is needed: merge, never replace, so the
// Viewer counts live inside media_info so no column is needed: merge, never replace, so the
// width/height the JSMPEG ingest path reported survive.
// width/height the JSMPEG ingest path reported survive.
viewers: db.prepare(`UPDATE ingest_sessions SET media_info = jsonb_set(COALESCE(NULLIF(media_info, '')::jsonb, '{}'::jsonb), '{viewers}', to_jsonb(?::bigint), true)::text WHERE id = ? AND state IN ${OPEN}`),
viewers: db.prepare(`UPDATE ingest_sessions SET media_info = jsonb_set(COALESCE(NULLIF(media_info, '')::jsonb, '{}'::jsonb), '{viewers}', to_jsonb(?::bigint), true)::text WHERE id = ? AND state IN ${OPEN}`),
+ // The live frame the owning worker uploaded to Media (decision 4): merged, like viewers, so
+ // the width/height a protocol reported survive.
+ thumbnail: db.prepare(`UPDATE ingest_sessions SET media_info = jsonb_set(COALESCE(NULLIF(media_info, '')::jsonb, '{}'::jsonb), '{thumbnail_url}', to_jsonb(?::text), true)::text WHERE id = ? AND state IN ${OPEN}`),
requestEnd: db.prepare(`UPDATE ingest_sessions SET desired_state = 'end', end_requested_by = ?, updated_at = ? WHERE id = ? AND state IN ${OPEN}`),
requestEnd: db.prepare(`UPDATE ingest_sessions SET desired_state = 'end', end_requested_by = ?, updated_at = ? WHERE id = ? AND state IN ${OPEN}`),
endRequests: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND desired_state = 'end' AND state IN ${OPEN}`),
endRequests: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND desired_state = 'end' AND state IN ${OPEN}`),
ofWorker: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND state IN ${OPEN}`),
ofWorker: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND state IN ${OPEN}`),
@@ -50,7 +53,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -50,7 +53,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
function shape(s) {
function shape(s) {
if (!s) return null;
if (!s) return null;
const media_info = parseJson(s.media_info, null);
const media_info = parseJson(s.media_info, null);
- return { ...s, media_info, viewers: (media_info && Number(media_info.viewers)) || 0 };
+ return { ...s, media_info, viewers: (media_info && Number(media_info.viewers)) || 0, thumbnail_url: (media_info && media_info.thumbnail_url) || null };
}
}
async function get(id) {
async function get(id) {
@@ -138,7 +141,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -138,7 +141,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
const session = shape(await q.get.get(current.id));
const session = shape(await q.get.get(current.id));
const definition = await definitions.row(session.definition_id);
const definition = await definitions.row(session.definition_id);
if (to === 'live') {
if (to === 'live') {
- await events.enqueue(await envelopeFor(TYPES.sessionStarted, session, definition, { playback: await playback(session) }));
+ await events.enqueue(await envelopeFor(TYPES.sessionStarted, session, definition, { thumbnail_url: session.thumbnail_url || null, playback: await playback(session) }));
} else if (to === 'ended' && session.live_at) {
} else if (to === 'ended' && session.live_at) {
await events.enqueue(await envelopeFor(TYPES.sessionEnded, session, definition, {
await events.enqueue(await envelopeFor(TYPES.sessionEnded, session, definition, {
ended_at: iso(session.ended_at),
ended_at: iso(session.ended_at),
@@ -201,6 +204,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -201,6 +204,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
state: session.state,
state: session.state,
live: session.state === 'live',
live: session.state === 'live',
worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation, state: worker.state } : null,
worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation, state: worker.state } : null,
+ thumbnail_url: session.thumbnail_url || null,
flv: null,
flv: null,
rtmp: null,
rtmp: null,
webrtc: null,
webrtc: null,
@@ -228,6 +232,14 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -228,6 +232,14 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
if (session.protocol === 'rtmp' && ep.rtmpPlayPort) {
if (session.protocol === 'rtmp' && ep.rtmpPlayPort) {
descriptor.rtmp = { internal_url: `rtmp://127.0.0.1:${ep.rtmpPlayPort}/live/${session.id}` };
descriptor.rtmp = { internal_url: `rtmp://127.0.0.1:${ep.rtmpPlayPort}/live/${session.id}` };
}
}
+ if (session.protocol === 'webrtc' && ep.publicPort) {
+ // The viewer signaling endpoint, keyed by the session's playback id (never the key), and
+ // the mediasoup announced IP clients must be able to reach for media to flow.
+ descriptor.webrtc = {
+ signaling_url: `ws://${config.webrtc.publicHost}:${ep.publicPort}/w/${session.id}`,
+ announced_ip: config.webrtc.media.announcedIp || null,
+ };
+ }
return descriptor;
return descriptor;
}
}
@@ -242,6 +254,22 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -242,6 +254,22 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
playback,
playback,
setMediaInfo: async (id, info) => (await q.mediaInfo.run(JSON.stringify(info), id)).changes > 0,
setMediaInfo: async (id, info) => (await q.mediaInfo.run(JSON.stringify(info), id)).changes > 0,
setViewers: async (id, count) => (await q.viewers.run(Math.max(0, Number(count) || 0), id)).changes > 0,
setViewers: async (id, count) => (await q.viewers.run(Math.max(0, Number(count) || 0), id)).changes > 0,
+ /**
+ * Record the live frame's Media URL (decision 4) and emit openre.session.updated. A no-op
+ * when the URL did not change, so the periodic grab does not publish an event every interval.
+ */
+ setThumbnail: async (id, url) => await db.tx(async () => {
+ const current = await q.get.get(id);
+ if (!current) return false;
+ const currentUrl = (parseJson(current.media_info, {}) || {}).thumbnail_url || null;
+ if (currentUrl === url) return false;
+ const changed = (await q.thumbnail.run(url || '', id)).changes > 0;
+ if (!changed) return false;
+ const session = shape(await q.get.get(id));
+ const definition = await definitions.row(session.definition_id);
+ await events.enqueue(await envelopeFor(TYPES.sessionUpdated, session, definition, { thumbnail_url: url || null }));
+ return true;
+ }),
transitions: async (id) => await q.transitions.all(id),
transitions: async (id) => await q.transitions.all(id),
endRequestsFor: async (workerId) => (await q.endRequests.all(workerId)).map(shape),
endRequestsFor: async (workerId) => (await q.endRequests.all(workerId)).map(shape),
ofWorker: async (workerId) => (await q.ofWorker.all(workerId)).map(shape),
ofWorker: async (workerId) => (await q.ofWorker.all(workerId)).map(shape),
@@ -11,7 +11,7 @@ const { createMediaClient } = require('../server/media-client');
@@ -11,7 +11,7 @@ const { createMediaClient } = require('../server/media-client');
const { createCoordinator } = require('../server/coordinator');
const { createCoordinator } = require('../server/coordinator');
const { createRtmpIngest } = require('../workers/rtmp-ingest');
const { createRtmpIngest } = require('../workers/rtmp-ingest');
const { createRestreamWorker } = require('../workers/restream-worker');
const { createRestreamWorker } = require('../workers/restream-worker');
-const { createUnportedTransport } = require('../workers/unported-transport');
+const { createWebrtc } = require('../workers/webrtc');
const { createJsmpeg } = require('../workers/jsmpeg');
const { createJsmpeg } = require('../workers/jsmpeg');
const { openDb } = require('../server/db');
const { openDb } = require('../server/db');
const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite, silent, testDb } = require('./helpers');
const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite, silent, testDb } = require('./helpers');
@@ -46,7 +46,7 @@ t('the coordinator and every transport worker refuse to be created in a drill',
@@ -46,7 +46,7 @@ t('the coordinator and every transport worker refuse to be created in a drill',
drill(() => createCoordinator({ rt, media: null, log: silent }));
drill(() => createCoordinator({ rt, media: null, log: silent }));
drill(() => createRtmpIngest({ rt, log: silent, exit: () => {} }));
drill(() => createRtmpIngest({ rt, log: silent, exit: () => {} }));
drill(() => createRestreamWorker({ rt, log: silent, exit: () => {} }));
drill(() => createRestreamWorker({ rt, log: silent, exit: () => {} }));
- drill(() => createUnportedTransport({ rt, kind: 'webrtc', log: silent, exit: () => {} }));
+ drill(() => createWebrtc({ rt, log: silent, exit: () => {} }));
drill(() => createJsmpeg({ rt, log: silent, exit: () => {} }));
drill(() => createJsmpeg({ rt, log: silent, exit: () => {} }));
assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM workers').get()).n, 0, 'no generation registered');
assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM workers').get()).n, 0, 'no generation registered');
assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM leases').get()).n, 0, 'no coordinator lease taken');
assert.strictEqual((await rt.db.prepare('SELECT count(*) AS n FROM leases').get()).n, 0, 'no coordinator lease taken');
@@ -195,7 +195,9 @@ t('the JSMPEG data tap restreams to a destination', async () => {
@@ -195,7 +195,9 @@ t('the JSMPEG data tap restreams to a destination', async () => {
t('recording is refused with a clear reason, without calling Media', async () => {
t('recording is refused with a clear reason, without calling Media', async () => {
const s = await waitFor(async () => { const d = await detail(sessionA.id); return d.recording && d.recording.state === 'failed' ? d : null; }, { what: 'recording failed', timeoutMs: 10000 });
const s = await waitFor(async () => { const d = await detail(sessionA.id); return d.recording && d.recording.state === 'failed' ? d : null; }, { what: 'recording failed', timeoutMs: 10000 });
assert.strictEqual(s.recording.last_error, 'recording is not available for jsmpeg');
assert.strictEqual(s.recording.last_error, 'recording is not available for jsmpeg');
- assert.strictEqual(mediaCalls.length, 0, 'Media was never asked to record a jsmpeg session');
+ // The worker does upload live-thumbnail objects (decision 4: `POST /api/v2/<app>/objects`), so
+ // only the recording endpoints must be untouched.
+ assert.strictEqual(mediaCalls.filter((c) => !/\/objects$/.test(c.url)).length, 0, 'Media was never asked to record a jsmpeg session');
});
});
t('a second publisher with the same key is refused while the first is live', async () => {
t('a second publisher with the same key is refused while the first is live', async () => {
@@ -115,7 +115,7 @@ t('apply: slots with a subject are imported with new keys; the rest are held or
@@ -115,7 +115,7 @@ t('apply: slots with a subject are imported with new keys; the rest are held or
assert.match(md, /## @japaneseoldguy/);
assert.match(md, /## @japaneseoldguy/);
assert.match(md, /ingest-authority/);
assert.match(md, /ingest-authority/);
assert.match(md, /OAuth-linked/);
assert.match(md, /OAuth-linked/);
- assert.match(md, /webrtc worker is not ported/, 'the WebRTC slot is flagged');
+ assert.match(md, /switch it after the WebRTC worker is live/, 'the WebRTC slot is flagged');
for (const secret of ['a3f9c2leakedkey', 'live_twitchsecret_1', 'yt-secret-2222', 'personalkey111', 'kick-secret-44']) assert.ok(!md.includes(secret), `no ${secret} in the checklist`);
for (const secret of ['a3f9c2leakedkey', 'live_twitchsecret_1', 'yt-secret-2222', 'personalkey111', 'kick-secret-44']) assert.ok(!md.includes(secret), `no ${secret} in the checklist`);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,298 @@
@@ -0,0 +1,298 @@
+'use strict';
+// End to end for the WebRTC worker with real mediasoup and a real headless WebRTC client (werift, the
+// Node peer Live also uses): a WHIP publisher's offer is answered and goes live when ICE connects, a
+// PlainRTP egress carries its RTP, a WS viewer consumes by playback id and is counted in the
+// heartbeat, a browser broadcaster produces over /b/<key>, admission refuses a bad key / a wrong
+// protocol / a duplicate slot, the coordinator records the session through Media's RTP ingest, a
+// lost lease ends the sessions and a drain closes the listener and exits.
+//
+// It needs mediasoup and werift, which are NOT installed in this repo (installs are not available
+// here): run it through the checkout that has them,
+// NODE_PATH=<OpenVibe.Live>/node_modules npm test -- webrtc-e2e
+// and it prints a skip line (not a failure) everywhere else.
+const assert = require('assert');
+const crypto = require('crypto');
+const dgram = require('dgram');
+const http = require('http');
+const WebSocket = require('ws');
+const { createWebrtc } = require('../workers/webrtc');
+const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');
+
+let werift;
+try {
+ werift = require('werift');
+ require.resolve('mediasoup');
+} catch (err) {
+ console.log('webrtc-e2e: skipped (mediasoup/werift not installed; run with NODE_PATH=<OpenVibe.Live>/node_modules)');
+ process.exit(0);
+}
+
+const t = suite('webrtc-e2e');
+
+function httpReq(port, method, path, { headers = {}, body } = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
+ let data = '';
+ res.on('data', (c) => { data += c; });
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
+ });
+ req.on('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+}
+
+function wsOpen(url) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(url);
+ ws.inbox = [];
+ ws.on('message', (d) => { try { ws.inbox.push(JSON.parse(d)); } catch { /* ignore */ } });
+ ws.on('error', () => {});
+ ws.once('open', () => resolve(ws));
+ ws.once('unexpected-response', (req, res) => reject(new Error(`http ${res.statusCode}`)));
+ ws.once('error', (err) => reject(err));
+ });
+}
+const wsSend = (ws, obj) => ws.send(JSON.stringify(obj));
+const wsWait = (ws, type, timeoutMs = 8000) => waitFor(() => ws.inbox.find((m) => m.type === type), { what: `ws ${type}`, timeoutMs });
+
+/** A fingerprinted DTLS fragment a client sends (format-valid; mediasoup does not handshake here). */
+const fakeFingerprint = () => ({ fingerprints: [{ algorithm: 'sha-256', value: crypto.randomBytes(32).toString('hex').match(/.{2}/g).join(':').toUpperCase() }] });
+
+/** Feed a track with VP8 RTP at a steady rate, the way a browser/encoder would. */
+function startDummyVideo(track, { fps = 30 } = {}) {
+ const KEYFRAME = Buffer.from([0x10, 0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00]);
+ const DELTA = Buffer.from([0x10, 0x11, 0x00, 0x00]);
+ let seq = 0x4000;
+ let timestamp = 0;
+ let n = 0;
+ const timer = setInterval(() => {
+ const header = new werift.RtpHeader({ version: 2, payloadType: 96, sequenceNumber: seq++, timestamp, ssrc: 0x1a2b3c00, marker: true });
+ track.writeRtp(new werift.RtpPacket(header, n % fps === 0 ? KEYFRAME : DELTA));
+ timestamp += 3000;
+ n++;
+ }, 1000 / fps);
+ timer.unref?.();
+ return { stop: () => clearInterval(timer) };
+}
+
+let rt;
+let worker;
+let exits;
+let port;
+let defA;
+let keyA;
+let sessionA;
+let pub;
+let udp;
+let mediaPort;
+const mediaCalls = [];
+
+t('setup: a real worker generation with mediasoup', async () => {
+ port = await freePort();
+ const rtcMin = 20000 + Math.floor(Math.random() * 20000);
+ const egressMin = await freePort();
+ rt = await runtime({
+ env: {
+ OPENRE_WEBRTC_PORT: String(port),
+ OPENRE_WEBRTC_BIND: '127.0.0.1',
+ OPENRE_WEBRTC_INTERNAL_PORT_MIN: String(egressMin),
+ OPENRE_WEBRTC_INTERNAL_PORT_MAX: String(egressMin + 20),
+ OPENRE_MEDIASOUP_MIN_PORT: String(rtcMin),
+ OPENRE_MEDIASOUP_MAX_PORT: String(rtcMin + 60),
+ MEDIASOUP_ANNOUNCED_IP: '127.0.0.1',
+ OPENRE_WORKER_HEARTBEAT_MS: '300',
+ OPENRE_DRAIN_MAX_MS: '900',
+ OPENRE_RECORDING_START_DELAY_MS: '0',
+ OPENRE_THUMBNAILS: 'off',
+ },
+ });
+ exits = [];
+ worker = createWebrtc({ rt, log: silent, exit: (code) => exits.push(code) });
+ await worker.start();
+ assert.strictEqual(worker.runtime.me.state, 'ready');
+ assert.ok(worker.runtime.me.generation >= 1);
+ assert.ok(worker.endpoints().egressPort > 0, 'the loopback egress endpoint is published');
+});
+
+t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives its RTP', async () => {
+ const created = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], recording_mode: 'vod', title: 'WebRTC A' });
+ defA = created.definition;
+ keyA = created.key.key;
+
+ const pc = new werift.RTCPeerConnection({});
+ const track = new werift.MediaStreamTrack({ kind: 'video' });
+ pc.addTransceiver(track, { direction: 'sendonly' });
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ const res = await httpReq(port, 'POST', `/whip/${keyA}`, { headers: { 'content-type': 'application/sdp' }, body: pc.localDescription.sdp });
+ assert.strictEqual(res.status, 201, `WHIP answer (${res.status}): ${res.body.slice(0, 200)}`);
+ assert.match(res.body, /a=recvonly/, 'the answer is a recvonly SDP');
+ assert.match(res.body, /a=ice-lite/);
+ assert.match(res.headers.location, /\/whip\/session\/[0-9a-f]{16,}$/, 'Location points at the session resource');
+ assert.match(String(res.headers.etag), /^"/, 'an ETag is returned');
+
+ await pc.setRemoteDescription({ type: 'answer', sdp: res.body });
+ pub = { pc, track, feed: startDummyVideo(track), resourceId: res.headers.location.split('/').pop() };
+ await waitFor(() => ['connected', 'completed'].includes(pc.connectionState), { what: 'WHIP publisher ICE connected', timeoutMs: 20000 });
+
+ const sess = await waitFor(async () => { const s = (await rt.store.sessions.ofWorker(worker.runtime.me.id)).find((x) => x.definition_id === defA.id); return s && s.state === 'live' ? s : null; }, { what: 'session live after ICE', timeoutMs: 15000 });
+ sessionA = sess.id;
+ const pb = await rt.store.sessions.playback(sess);
+ assert.ok(pb.webrtc.signaling_url.endsWith(`/w/${sessionA}`));
+ assert.ok(!pb.webrtc.signaling_url.includes(keyA), 'the key is never in the viewer URL');
+
+ // A PlainRTP consumer (what restream / Media's RTP recorder / thumbnails use) must receive media.
+ udp = dgram.createSocket('udp4');
+ await new Promise((r) => udp.bind(0, '127.0.0.1', r));
+ udp.packets = 0;
+ udp.on('message', () => { udp.packets++; });
+ const vport = udp.address().port;
+ const egress = await httpReq(worker.endpoints().egressPort, 'GET', `/rtp/${sessionA}?vport=${vport}`);
+ assert.strictEqual(egress.status, 200, `egress descriptor (${egress.status}): ${egress.body}`);
+ const desc = JSON.parse(egress.body);
+ assert.match(desc.sdp, /m=video \d+ RTP\/AVP/);
+ assert.ok(desc.handle);
+ udp.handle = desc.handle;
+ await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });
+
+ const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });
+ assert.strictEqual(patch.status, 204, 'trickle ICE is acknowledged');
+});
+
+t('a WS viewer consumes by playback id and is counted in the heartbeat', async () => {
+ const playback = await rt.store.sessions.playback(await rt.store.sessions.get(sessionA));
+ const ws = await wsOpen(playback.webrtc.signaling_url);
+ const welcome = await wsWait(ws, 'welcome');
+ assert.strictEqual(welcome.role, 'viewer');
+ wsSend(ws, { type: 'watch' });
+ const ready = await wsWait(ws, 'sfu-viewer-ready');
+ assert.ok(ready.rtpCapabilities.codecs.length > 0);
+ const video = ready.producers.find((p) => p.kind === 'video');
+ assert.ok(video, 'the WHIP video producer is advertised to the viewer');
+
+ wsSend(ws, { type: 'sfu-viewer-create-transport' });
+ const transport = await wsWait(ws, 'sfu-viewer-transport-created');
+ assert.ok(transport.iceCandidates.length > 0);
+ wsSend(ws, { type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters: { role: 'client', ...fakeFingerprint() } });
+ await wsWait(ws, 'sfu-viewer-transport-connected');
+ wsSend(ws, { type: 'sfu-viewer-consume', transportId: transport.id, producerId: video.id, rtpCapabilities: ready.rtpCapabilities });
+ const consumed = await wsWait(ws, 'sfu-viewer-consumed');
+ assert.strictEqual(consumed.kind, 'video');
+
+ await worker.runtime.beat();
+ const s = await waitFor(async () => { const x = await rt.store.sessions.get(sessionA); return x.viewers >= 1 ? x : null; }, { what: 'viewer count in the heartbeat', timeoutMs: 8000 });
+ assert.ok(s.viewers >= 1, 'the consumer peer is counted');
+ ws.close();
+});
+
+t('the coordinator records the session through Media\'s RTP ingest over the real egress', async () => {
+ mediaPort = await freePort();
+ const media = {
+ configured: true,
+ appId: 'live',
+ createVod: async () => { mediaCalls.push('createVod'); return { id: 'vod-e2e' }; },
+ ingestRtpStart: async (vodId, codecs) => { mediaCalls.push(`ingestRtpStart:${codecs.video.codec}`); return { videoPort: mediaPort, audioPort: null }; },
+ ingestRtpStop: async () => { mediaCalls.push('ingestRtpStop'); },
+ finalizeVod: async () => { mediaCalls.push('finalizeVod'); },
+ deleteVod: async () => { mediaCalls.push('deleteVod'); },
+ };
+ assert.strictEqual(await rt.store.recordings.ensureRequests(), 1);
+ await rt.store.recordings.process(media);
+ let rec = await rt.store.recordings.bySession(sessionA);
+ assert.strictEqual(rec.state, 'recording');
+ assert.ok(rec.rtp_handle, 'the worker egress handle is remembered');
+ assert.strictEqual(rec.rtp_video_port, mediaPort);
+ assert.ok(mediaCalls.includes('ingestRtpStart:VP8'), 'Media was asked for RTP ports with the codec');
+});
+
+t('a browser broadcaster produces over /b/<key> and goes live', async () => {
+ const created = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], title: 'WebRTC B' });
+ const ws = await wsOpen(`ws://127.0.0.1:${port}/b/${created.key.key}`);
+ const welcome = await wsWait(ws, 'welcome');
+ assert.strictEqual(welcome.role, 'broadcaster');
+ wsSend(ws, { type: 'sfu-get-capabilities' });
+ const caps = await wsWait(ws, 'sfu-capabilities');
+ assert.ok(caps.rtpCapabilities.codecs.length > 0);
+ wsSend(ws, { type: 'sfu-create-transport' });
+ const transport = await wsWait(ws, 'sfu-transport-created');
+ wsSend(ws, { type: 'sfu-connect-transport', transportId: transport.id, dtlsParameters: { role: 'client', ...fakeFingerprint() } });
+ await wsWait(ws, 'sfu-transport-connected');
+ wsSend(ws, {
+ type: 'sfu-produce', transportId: transport.id, kind: 'video',
+ rtpParameters: { mid: '0', codecs: [{ mimeType: 'video/VP8', payloadType: 96, clockRate: 90000, parameters: {}, rtcpFeedback: [] }], headerExtensions: [], encodings: [{ ssrc: 24681012 }] },
+ });
+ const produced = await wsWait(ws, 'sfu-produced');
+ assert.strictEqual(produced.kind, 'video');
+ const live = await waitFor(async () => {
+ const s = (await rt.store.sessions.ofWorker(worker.runtime.me.id)).find((x) => x.definition_id === created.definition.id);
+ return s && s.state === 'live' ? s : null;
+ }, { what: 'browser session live', timeoutMs: 8000 });
+ assert.strictEqual(live.protocol, 'webrtc');
+
+ // Admission while this slot is already open is refused.
+ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/${created.key.key}`), /http 403/, 'a duplicate broadcaster is refused');
+ ws.close();
+});
+
+t('admission refuses a bad key and a wrong-protocol key', async () => {
+ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/ork_${'x'.repeat(43)}`), /http 403/, 'bad key refused');
+ const wrong = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['rtmp'] });
+ await assert.rejects(() => wsOpen(`ws://127.0.0.1:${port}/b/${wrong.key.key}`), /http 403/, 'rtmp-only slot refused on the WebRTC listener');
+});
+
+t('DELETE ends the WHIP session', async () => {
+ const res = await httpReq(port, 'DELETE', `/whip/session/${pub.resourceId}`);
+ assert.strictEqual(res.status, 200);
+ const s = await waitFor(async () => { const x = await rt.store.sessions.get(sessionA); return ['ended', 'failed'].includes(x.state) ? x : null; }, { what: 'WHIP session ended', timeoutMs: 8000 });
+ assert.strictEqual(s.state, 'ended');
+ pub.feed.stop();
+ pub.pc.close();
+});
+
+t('a lost lease ends the worker\'s sessions and exits', async () => {
+ const port2 = await freePort();
+ const rtcMin = 20000 + Math.floor(Math.random() * 20000);
+ const egressMin = await freePort();
+ const rt2 = await runtime({ env: {
+ OPENRE_WEBRTC_PORT: String(port2), OPENRE_WEBRTC_BIND: '127.0.0.1',
+ OPENRE_WEBRTC_INTERNAL_PORT_MIN: String(egressMin), OPENRE_WEBRTC_INTERNAL_PORT_MAX: String(egressMin + 20),
+ OPENRE_MEDIASOUP_MIN_PORT: String(rtcMin), OPENRE_MEDIASOUP_MAX_PORT: String(rtcMin + 60),
+ MEDIASOUP_ANNOUNCED_IP: '127.0.0.1', OPENRE_WORKER_HEARTBEAT_MS: '300', OPENRE_THUMBNAILS: 'off',
+ } });
+ const exits2 = [];
+ const w2 = createWebrtc({ rt: rt2, log: silent, exit: (code) => exits2.push(code) });
+ await w2.start();
+ const created = await rt2.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });
+ // Admit through the real signaling path so the session lives in the worker's own map.
+ const ws = await wsOpen(`ws://127.0.0.1:${port2}/b/${created.key.key}`);
+ await wsWait(ws, 'welcome');
+ const sess = await waitFor(async () => (await rt2.store.sessions.ofWorker(w2.runtime.me.id))[0], { what: 'admitted session' });
+ await rt2.store.workers.lose(w2.runtime.me.id, 'test');
+ await w2.runtime.beat();
+ await waitFor(() => exits2.length > 0, { what: 'lost worker exit', timeoutMs: 8000 });
+ assert.strictEqual(exits2[0], 1, 'a lost worker exits non-zero');
+ const s = await waitFor(async () => { const x = await rt2.store.sessions.get(sess.id); return ['ended', 'failed'].includes(x.state) ? x : null; }, { what: 'session ended after lease loss', timeoutMs: 8000 });
+ assert.ok(s, 'the session is not left live');
+ ws.close();
+ await w2.close();
+});
+
+t('a drain closes the listener, ends what is left and exits cleanly', async () => {
+ await worker.drain('test');
+ const refused = await waitFor(async () => {
+ try { await httpReq(port, 'POST', `/whip/${keyA}`, { body: 'v=0' }); return false; } catch (err) { return /ECONNREFUSED/.test(err.message); }
+ }, { what: 'the public listener closed to new sessions', timeoutMs: 5000 });
+ assert.ok(refused, 'the public listener is closed to new sessions');
+ await waitFor(() => exits.length > 0, { what: 'drained worker exit', timeoutMs: 10000 });
+ assert.strictEqual(exits[0], 0, 'a drained generation exits 0');
+ if (udp) { try { udp.close(); } catch { /* gone */ } }
+});
+
+t('teardown', async () => {
+ await sleep(50);
+ if (worker) await worker.close().catch(() => {});
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+// The WebRTC worker's store-level and pure behaviour, without mediasoup or PostgreSQL: config
+// defaults, the WHIP SDP bridge, the { webrtc } playback descriptor, thumbnails (grab + Media
+// object upload + openre.session.updated) and the coordinator's WebRTC RTP recording path, all
+// against stubs. The real mediasoup end-to-end lives in webrtc-e2e.test.js (needs PostgreSQL).
+const assert = require('assert');
+const crypto = require('crypto');
+const { load } = require('../server/config');
+const sdp = require('../workers/webrtc/sdp');
+const { webrtcArgs } = require('../workers/restream/ffmpeg-args');
+const { createThumbnailer } = require('../workers/thumbnails');
+const { createMediaClient } = require('../server/media-client');
+const { runtime, tmpDir, suite, OWNER, outboxEnvelopes } = require('./helpers');
+
+const t = suite('webrtc');
+
+/** A minimal but real WHIP offer (VP8 + a DTLS fingerprint built at runtime, as a browser sends). */
+function fixtureOffer() {
+ const fp = crypto.randomBytes(32).toString('hex').match(/.{2}/g).join(':').toUpperCase();
+ return [
+ 'v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=-', 't=0 0',
+ 'a=group:BUNDLE 0', 'a=msid-semantic: WMS',
+ 'm=video 9 UDP/TLS/RTP/SAVPF 96', 'c=IN IP4 0.0.0.0', 'a=rtcp:9 IN IP4 0.0.0.0',
+ 'a=ice-ufrag:abcd', 'a=ice-pwd:abcdefghijklmnopqrstuvwx', 'a=ice-options:trickle',
+ `a=fingerprint:sha-256 ${fp}`, 'a=setup:actpass', 'a=mid:0', 'a=sendonly', 'a=rtcp-mux',
+ 'a=rtpmap:96 VP8/90000', 'a=rtcp-fb:96 nack', 'a=rtcp-fb:96 nack pli', 'a=rtcp-fb:96 goog-remb',
+ 'a=ssrc:11111111 cname:test', 'a=ssrc:11111111 msid:stream track', '',
+ ].join('\r\n');
+}
+
+t('config: the WebRTC ports never collide with Live and the RTC range is OpenRe\'s own', () => {
+ const c = load({});
+ assert.strictEqual(c.webrtc.port, 9936);
+ assert.strictEqual(c.webrtc.media.minPort, 10200);
+ assert.strictEqual(c.webrtc.media.maxPort, 10300);
+ assert.notStrictEqual(c.webrtc.media.minPort, 10000, 'never Live\'s range');
+ assert.strictEqual(c.webrtc.media.workers, 1);
+ assert.deepStrictEqual(c.webrtc.media.mediaCodecs.map(x => x.mimeType), ['audio/opus', 'video/VP8', 'video/H264']);
+ assert.strictEqual(load({ MEDIASOUP_ANNOUNCED_IP: '203.0.113.7' }).webrtc.media.announcedIp, '203.0.113.7');
+ assert.strictEqual(load({ OPENRE_WEBRTC_STUN: 'stun:a:1,stun:b:2' }).webrtc.stunUrls.length, 2);
+});
+
+t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
+ if (!sdp.available()) { console.log(' skip sdp-transform not installed'); return; }
+ const offer = sdp.parse(fixtureOffer());
+ assert.strictEqual(sdp.getDtlsSetupAttribute(offer), 'actpass');
+ const dtls = sdp.extractDtlsParameters(offer);
+ assert.strictEqual(dtls.role, 'client', 'actpass → the remote is the DTLS client');
+ assert.strictEqual(dtls.fingerprints[0].algorithm, 'sha-256');
+
+ const caps = {
+ codecs: [{ mimeType: 'video/VP8', clockRate: 90000, preferredPayloadType: 96 }],
+ headerExtensions: [],
+ };
+ const rtp = sdp.extractRtpParameters(offer.media[0], caps, 0);
+ assert.strictEqual(rtp.codecs[0].mimeType, 'video/VP8');
+ assert.deepStrictEqual(rtp.encodings[0], { ssrc: 11111111 });
+ assert.strictEqual(rtp.mid, '0');
+
+ const transport = { iceParameters: { usernameFragment: 'uf', password: 'pw' }, iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 1, ip: '203.0.113.7', port: 10200, type: 'host' }], dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }] } };
+ const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRe' });
+ const parsedAnswer = sdp.parse(answer);
+ assert.strictEqual(parsedAnswer.media[0].direction, 'recvonly');
+ assert.strictEqual(parsedAnswer.media[0].setup, 'passive');
+ assert.strictEqual(parsedAnswer.icelite, 'ice-lite');
+ assert.match(answer, /a=rtpmap:96 VP8\/90000/);
+});
+
+t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {
+ const rt = await runtime({ dir: tmpDir(), env: { MEDIASOUP_ANNOUNCED_IP: '203.0.113.7' } });
+ const { definition, key } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });
+ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });
+ await rt.store.workers.ready(w.id);
+ const a = await rt.store.sessions.admit({ definition, key, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });
+ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });
+
+ const pb = await rt.store.sessions.playback(await rt.store.sessions.get(a.session.id));
+ assert.deepStrictEqual(Object.keys(pb.webrtc).sort(), ['announced_ip', 'signaling_url']);
+ assert.strictEqual(pb.webrtc.signaling_url, `ws://127.0.0.1:9936/w/${a.session.id}`);
+ assert.ok(!pb.webrtc.signaling_url.includes(key.key), 'the key is never in the viewer URL');
+ assert.strictEqual(pb.webrtc.announced_ip, '203.0.113.7');
+ assert.strictEqual(pb.thumbnail_url, null);
+
+ // The ingest endpoints an encoder/UI needs.
+ const ep = await rt.store.definitions.ingestEndpoints(await rt.store.definitions.get(definition.id));
+ assert.strictEqual(ep.webrtc.whip_url, 'http://127.0.0.1:9936/whip');
+ assert.strictEqual(ep.webrtc.signaling_url, 'ws://127.0.0.1:9936/b');
+ rt.db.close();
+});
+
+t('a thumbnail is recorded on the session and emits openre.session.updated once', async () => {
+ const rt = await runtime({ dir: tmpDir() });
+ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'] });
+ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });
+ await rt.store.workers.ready(w.id);
+ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });
+ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });
+
+ assert.strictEqual(await rt.store.sessions.setThumbnail(a.session.id, 'https://media.test/o/t1'), true);
+ assert.strictEqual(await rt.store.sessions.setThumbnail(a.session.id, 'https://media.test/o/t1'), false, 'unchanged → no second event');
+ const s = await rt.store.sessions.get(a.session.id);
+ assert.strictEqual(s.thumbnail_url, 'https://media.test/o/t1');
+ const ev = (await outboxEnvelopes(rt.db)).filter(e => e.event_type === 'openre.session.updated');
+ assert.strictEqual(ev.length, 1);
+ assert.strictEqual(ev[0].payload.thumbnail_url, 'https://media.test/o/t1');
+ rt.db.close();
+});
+
+t('a grabber captures a frame (ffmpeg stdout) and uploads it to Media as an object', async () => {
+ const rt = await runtime({ dir: tmpDir() });
+ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['rtmp'] });
+ const w = await rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: {} });
+ await rt.store.workers.ready(w.id);
+ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'rtmp', worker: await rt.store.workers.get(w.id) });
+ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });
+
+ const uploads = [];
+ const media = { configured: true, uploadObject: async (o) => { uploads.push(o); return { id: 'o1', url: 'https://media.test/o/o1' }; } };
+ // A fake ffmpeg: emits a JPEG-ish buffer on stdout and exits 0.
+ const fakeSpawn = () => {
+ const { EventEmitter } = require('events');
+ const p = new EventEmitter();
+ p.stdout = new EventEmitter(); p.stderr = new EventEmitter();
+ p.kill = () => {};
+ setImmediate(() => { p.stdout.emit('data', Buffer.alloc(4000, 1)); p.emit('close', 0); });
+ return p;
+ };
+ const th = createThumbnailer({ config: rt.config, media, spawnImpl: fakeSpawn });
+ assert.strictEqual(th.enabled, true);
+ const buf = await th.capture(['-i', 'http://127.0.0.1/x.flv']);
+ assert.ok(buf && buf.length === 4000);
+ const url = await th.publish(rt.store, a.session.id, buf, { protocol: 'rtmp' });
+ assert.strictEqual(url, 'https://media.test/o/o1');
+ assert.strictEqual(uploads[0].namespace, 'live');
+ assert.strictEqual(uploads[0].mimeType, 'image/jpeg');
+ assert.strictEqual((await rt.store.sessions.get(a.session.id)).thumbnail_url, url);
+ rt.db.close();
+});
+
+t('media-client.uploadObject does init → PUT → complete', async () => {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ calls.push({ url: String(url), method: opts.method || 'GET' });
+ if (String(url).endsWith('/objects')) return { ok: true, status: 201, text: async () => JSON.stringify({ id: 'o1', upload: { url: 'https://m/o1/content', complete_url: 'https://m/o1/complete', method: 'PUT' } }) };
+ if (String(url).endsWith('/o1/content')) return { ok: true, status: 200, text: async () => '' };
+ if (String(url).endsWith('/o1/complete')) return { ok: true, status: 200, text: async () => JSON.stringify({ object: { id: 'o1', public_url: 'https://media.test/o/o1' } }) };
+ return { ok: false, status: 404, text: async () => '{}' };
+ };
+ const config = load({ MEDIA_URL: 'http://127.0.0.1:4100', MEDIA_API_KEY: 'k'.repeat(40), MEDIA_APP_ID: 'live' });
+ const media = createMediaClient({ config, fetchImpl });
+ const r = await media.uploadObject({ namespace: 'live', mimeType: 'image/jpeg', filename: 'x.jpg', bytes: Buffer.alloc(10, 1) });
+ assert.strictEqual(r.url, 'https://media.test/o/o1');
+ assert.deepStrictEqual(calls.map(c => c.method), ['POST', 'PUT', 'POST']);
+ assert.ok(calls[0].url.includes('/api/v2/live/objects'));
+});
+
+t('restream args: webrtcArgs reads the worker\'s SDP file and re-encodes', () => {
+ const args = webrtcArgs('/tmp/x.sdp', 'rtmp://example/app/key', { videoBitrate: '3000k', maxrate: '3500k', bufsize: '3000k', audioBitrate: '128k', preset: 'veryfast', scale: '1280:720', fps: 30, gop: 60 }, { hasAudio: true });
+ assert.strictEqual(args[args.indexOf('-i') + 1], '/tmp/x.sdp');
+ assert.ok(args.includes('-protocol_whitelist'));
+ assert.strictEqual(args[args.indexOf('-protocol_whitelist') + 1], 'file,rtp,udp');
+ assert.ok(args.includes('libx264'));
+ assert.strictEqual(args[args.length - 1], 'rtmp://example/app/key');
+});
+
+t('the coordinator records a webrtc session through Media\'s RTP ingest and closes the egress', async () => {
+ const egress = [];
+ const fetchImpl = async (url, opts = {}) => {
+ const u = String(url);
+ egress.push(`${opts.method || 'GET'} ${u}`);
+ if (u.endsWith('/describe')) return { ok: true, status: 200, text: async () => JSON.stringify({ video: { payloadType: 96, codec: 'VP8', clockRate: 90000 }, audio: null }) };
+ if (u.includes('/rtp/') && (opts.method || 'GET') === 'GET') return { ok: true, status: 200, text: async () => JSON.stringify({ handle: 'h1', sdp: 'v=0\r\n', transports: ['t1'] }) };
+ if (u.includes('/rtp/') && opts.method === 'DELETE') return { ok: true, status: 200, text: async () => JSON.stringify({ ok: true }) };
+ return { ok: false, status: 404, text: async () => '{}' };
+ };
+ const rt = await runtime({ dir: tmpDir(), env: { OPENRE_RECORDING_START_DELAY_MS: '0' }, fetchImpl });
+ const { definition } = await rt.store.definitions.create({ owner_subject: OWNER, protocols: ['webrtc'], recording_mode: 'vod' });
+ const w = await rt.store.workers.register({ kind: 'webrtc', endpoints: { publicPort: 9936, egressPort: 19810 } });
+ await rt.store.workers.ready(w.id);
+ const a = await rt.store.sessions.admit({ definition, key: null, protocol: 'webrtc', worker: await rt.store.workers.get(w.id) });
+ await rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing', actor: 'worker:w' });
+
+ const calls = [];
+ const media = {
+ configured: true, appId: 'live',
+ createVod: async () => { calls.push('createVod'); return { id: 'vod1' }; },
+ ingestRtpStart: async (vodId, codecs) => { calls.push(`ingestRtpStart:${codecs.video.codec}`); return { videoPort: 12000, audioPort: 12002 }; },
+ ingestRtpStop: async () => { calls.push('ingestRtpStop'); },
+ finalizeVod: async () => { calls.push('finalizeVod'); },
+ deleteVod: async () => {},
+ };
+ assert.strictEqual(await rt.store.recordings.ensureRequests(), 1);
+ await rt.store.recordings.process(media);
+ let rec = await rt.store.recordings.bySession(a.session.id);
+ assert.strictEqual(rec.state, 'recording');
+ assert.strictEqual(rec.rtp_handle, 'h1');
+ assert.ok(egress.some(e => e.includes('/describe')));
+ assert.ok(calls.includes('ingestRtpStart:VP8'), 'Media was asked for RTP ports with the codec');
+
+ await rt.store.sessions.finish(a.session.id, { reason: 'test', actor: 'w' });
+ await rt.store.recordings.process(media);
+ rec = await rt.store.recordings.bySession(a.session.id);
+ assert.strictEqual(rec.state, 'finalized');
+ assert.ok(calls.includes('ingestRtpStop'));
+ assert.ok(calls.includes('finalizeVod'));
+ assert.ok(egress.some(e => e.startsWith('DELETE')), 'the egress consumer was closed');
+ rt.db.close();
+});
+
+t.run();
@@ -29,6 +29,8 @@ const http = require('http');
@@ -29,6 +29,8 @@ const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
const path = require('path');
const path = require('path');
const { createWorkerRuntime } = require('./runtime');
const { createWorkerRuntime } = require('./runtime');
+const { createThumbnailer } = require('./thumbnails');
+const { createMediaClient } = require('../server/media-client');
const SESSION_ID_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
const SESSION_ID_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
const INGEST_PATH_RE = /^\/([^/]+)\/(\d{1,5})\/(\d{1,5})\/?$/;
const INGEST_PATH_RE = /^\/([^/]+)\/(\d{1,5})\/(\d{1,5})\/?$/;
@@ -69,9 +71,10 @@ function listenInRange(server, min, max, skip = new Set()) {
@@ -69,9 +71,10 @@ function listenInRange(server, min, max, skip = new Set()) {
});
});
}
}
-function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }) {
+function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code), fetchImpl, spawnImpl }) {
const { store, config } = rt;
const { store, config } = rt;
const j = config.jsmpeg;
const j = config.jsmpeg;
+ const thumbnails = createThumbnailer({ config, log, media: createMediaClient({ config, ...(fetchImpl ? { fetchImpl } : {}) }), ...(spawnImpl ? { spawnImpl } : {}) });
/** session id → { sessionId, definitionId, width, height, req, ended, live, endReason, viewers:Set, taps:Set } */
/** session id → { sessionId, definitionId, width, height, req, ended, live, endReason, viewers:Set, taps:Set } */
const channels = new Map();
const channels = new Map();
// Ends in flight. The heartbeat calls exitWhenIdle() every beat; without this an ended session
// Ends in flight. The heartbeat calls exitWhenIdle() every beat; without this an ended session
@@ -136,7 +139,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
@@ -136,7 +139,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
req.destroy();
req.destroy();
return;
return;
}
}
- ch = { sessionId: adm.sessionId, definitionId: adm.definitionId, req, width, height, live: false, ended: false, endReason: null, viewers: new Set(), taps: new Set() };
+ ch = { sessionId: adm.sessionId, definitionId: adm.definitionId, req, width, height, live: false, ended: false, endReason: null, viewers: new Set(), taps: new Set(), thumbTimer: null };
channels.set(ch.sessionId, ch);
channels.set(ch.sessionId, ch);
log.log(`[jsmpeg] session ${ch.sessionId} accepted for ${ch.definitionId} (key …${adm.hint}) at ${width}x${height}`);
log.log(`[jsmpeg] session ${ch.sessionId} accepted for ${ch.definitionId} (key …${adm.hint}) at ${width}x${height}`);
res.writeHead(200, { 'content-type': 'video/mp2t' });
res.writeHead(200, { 'content-type': 'video/mp2t' });
@@ -165,7 +168,22 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
@@ -165,7 +168,22 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
log.warn(`[jsmpeg] session ${ch.sessionId} could not go live (${t.code}); closing`);
log.warn(`[jsmpeg] session ${ch.sessionId} could not go live (${t.code}); closing`);
ch.endReason = 'coordinator_failed';
ch.endReason = 'coordinator_failed';
try { if (ch.req && !ch.req.destroyed) ch.req.destroy(); } catch { /* gone */ }
try { if (ch.req && !ch.req.destroyed) ch.req.destroy(); } catch { /* gone */ }
+ return;
}
}
+ startThumbnails(ch);
+ }
+
+ /** Live thumbnails from this worker's loopback MPEG-TS tap (decision 4). Best effort. */
+ function startThumbnails(ch) {
+ if (!thumbnails.enabled || ch.thumbTimer || !tapServer) return;
+ const tapUrl = `http://127.0.0.1:${tapServer.address().port}/tap/${ch.sessionId}.ts`;
+ const grab = async () => {
+ try { const buf = await thumbnails.capture(['-f', 'mpegts', '-i', tapUrl]); if (buf) await thumbnails.publish(store, ch.sessionId, buf, { protocol: 'jsmpeg', width: ch.width, height: ch.height }); }
+ catch (err) { log.warn(`[jsmpeg] thumbnail ${ch.sessionId}: ${err.message}`); }
+ };
+ ch.thumbTimer = setInterval(() => grab().catch(() => {}), config.webrtc.thumbnails.intervalMs);
+ ch.thumbTimer.unref?.();
+ grab().catch(() => {});
}
}
function feed(ch, chunk) {
function feed(ch, chunk) {
@@ -182,6 +200,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
@@ -182,6 +200,7 @@ function createJsmpeg({ rt, log = console, exit = (code) => process.exit(code) }
if (ch.ended) return;
if (ch.ended) return;
ch.ended = true;
ch.ended = true;
channels.delete(ch.sessionId);
channels.delete(ch.sessionId);
+ if (ch.thumbTimer) { clearInterval(ch.thumbTimer); ch.thumbTimer = null; }
pendingEnds++;
pendingEnds++;
try {
try {
for (const v of ch.viewers) { try { v.socket.end(wsFrame(OP_CLOSE, Buffer.alloc(0))); } catch { /* gone */ } }
for (const v of ch.viewers) { try { v.socket.end(wsFrame(OP_CLOSE, Buffer.alloc(0))); } catch { /* gone */ } }
@@ -62,11 +62,22 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
@@ -62,11 +62,22 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
// A draining generation starts nothing new: an output assigned to it but not started yet
// A draining generation starts nothing new: an output assigned to it but not started yet
// goes back to the coordinator for the newest generation.
// goes back to the coordinator for the newest generation.
if (runtime.draining) { await store.outputs.unassign(row.id); seen.delete(row.id); continue; }
if (runtime.draining) { await store.outputs.unassign(row.id); seen.delete(row.id); continue; }
- const input = await inputUrlFor(session);
- if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }
+ // The source URL for RTMP (loopback FLV) and JSMPEG (data tap); WebRTC has no pullable
+ // URL — the runner asks the owning worker for a PlainRTP egress instead (egressBase).
+ let input = null;
+ let egressBase = null;
+ if (session.protocol === 'webrtc') {
+ const owner = session.worker_id ? await store.workers.get(session.worker_id) : null;
+ const ep = (owner && owner.endpoints) || {};
+ if (!ep.egressPort) { await store.outputs.report(row.id, { state: 'failed', last_error: 'the WebRTC worker for this session exposes no egress endpoint', ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }
+ egressBase = `http://127.0.0.1:${ep.egressPort}`;
+ } else {
+ input = await inputUrlFor(session);
+ if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }
+ }
// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).
// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).
if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;
if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;
- runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, source: session.protocol, store, config, log, spawnImpl, lookup, workerId: runtime.me.id });
+ runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, source: session.protocol, sessionId: session.id, egressBase, store, config, log, spawnImpl, lookup, workerId: runtime.me.id });
runners.set(row.id, runner);
runners.set(row.id, runner);
runner.start().catch((err) => runner.fail(err.message, { cooldown: false }));
runner.start().catch((err) => runner.fail(err.message, { cooldown: false }));
}
}
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
* restreams correctly from Live restreams the same way from OpenRe.
* restreams correctly from Live restreams the same way from OpenRe.
*
*
* RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,
* RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,
- * read over HTTP) is re-encoded with encodingArgs(). The WebRTC (mediasoup PlainRTP → SDP) source
- * stays in Live until that ingest protocol moves.
+ * read over HTTP) is re-encoded with encodingArgs(). A WebRTC source is mediasoup PlainRTP read
+ * from an SDP file the owning worker wrote (rtpInputArgs/webrtcArgs), also re-encoded.
*/
*/
const SRT_DEFAULT_LATENCY_MS = 120;
const SRT_DEFAULT_LATENCY_MS = 120;
@@ -89,6 +89,32 @@ function withProgress(args) {
@@ -89,6 +89,32 @@ function withProgress(args) {
: ['-progress', 'pipe:1', ...args];
: ['-progress', 'pipe:1', ...args];
}
}
+/**
+ * ffmpeg input flags for a PlainRTP consumer described by an SDP file (Live's
+ * RestreamManager._startWebrtcRestream): enough analyze/probe room and reorder space that a VP8
+ * keyframe is never dropped as "old packet received too late" (which would leave the encoder with
+ * no size and never go live). Kept byte-for-byte with Live's working flags.
+ */
+function rtpInputArgs(sdpPath) {
+ return [
+ '-protocol_whitelist', 'file,rtp,udp',
+ '-thread_queue_size', '2048',
+ '-analyzeduration', '10000000',
+ '-probesize', '10000000',
+ '-reorder_queue_size', '2048',
+ '-use_wallclock_as_timestamps', '1',
+ '-fflags', '+genpts+discardcorrupt+nobuffer+igndts',
+ '-err_detect', 'ignore_err',
+ '-avoid_negative_ts', 'make_zero',
+ '-i', sdpPath,
+ ];
+}
+
+/** WebRTC (mediasoup PlainRTP → SDP file) → destination, re-encoded (VP8/Opus → H.264/AAC). */
+function webrtcArgs(sdpPath, destUrl, preset, { hasAudio = true, overrides = {} } = {}) {
+ return ['-hide_banner', '-loglevel', 'warning', ...rtpInputArgs(sdpPath), ...encodingArgs(preset, { hasAudio, overrides }), ...outputArgs(destUrl)];
+}
+
function resolvePreset(destination) {
function resolvePreset(destination) {
const key = destination && destination.quality_preset || 'auto';
const key = destination && destination.quality_preset || 'auto';
if (key !== 'auto' && QUALITY_PRESETS[key]) return QUALITY_PRESETS[key];
if (key !== 'auto' && QUALITY_PRESETS[key]) return QUALITY_PRESETS[key];
@@ -164,5 +190,5 @@ function redactUrl(value) {
@@ -164,5 +190,5 @@ function redactUrl(value) {
module.exports = {
module.exports = {
QUALITY_PRESETS, PLATFORM_DEFAULT_PRESET, ENCODER_PRESETS, SRT_DEFAULT_LATENCY_MS,
QUALITY_PRESETS, PLATFORM_DEFAULT_PRESET, ENCODER_PRESETS, SRT_DEFAULT_LATENCY_MS,
- isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, jsmpegArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl, redactText,
+ isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, jsmpegArgs, webrtcArgs, rtpInputArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl, redactText,
};
};
@@ -8,17 +8,36 @@
@@ -8,17 +8,36 @@
*/
*/
const { spawn } = require('child_process');
const { spawn } = require('child_process');
const fs = require('fs');
const fs = require('fs');
-const { rtmpCopyArgs, jsmpegArgs, withProgress, buildDestUrl, resolvePreset, customOverrides, friendlyError, redactUrl, redactText } = require('./ffmpeg-args');
+const os = require('os');
+const path = require('path');
+const dgram = require('dgram');
+const { rtmpCopyArgs, jsmpegArgs, webrtcArgs, withProgress, buildDestUrl, resolvePreset, customOverrides, friendlyError, redactUrl, redactText } = require('./ffmpeg-args');
const { validateDestinationUrl, checkResolvedHost } = require('../../server/destination-url');
const { validateDestinationUrl, checkResolvedHost } = require('../../server/destination-url');
+/** A free even UDP port (the WebRTC source's ffmpeg takes it and its +1 for RTCP). */
+function freeUdpPort() {
+ return new Promise((resolve, reject) => {
+ const s = dgram.createSocket('udp4');
+ s.once('error', reject);
+ s.bind(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
+ });
+}
+async function allocateUdpPortPair() { const p = await freeUdpPort(); return p % 2 === 0 ? p : await freeUdpPort(); }
+
class OutputRunner {
class OutputRunner {
- constructor({ outputId, destinationId, inputUrl, source = 'rtmp', store, config, log = console, spawnImpl = spawn, lookup, workerId = null }) {
+ constructor({ outputId, destinationId, inputUrl, source = 'rtmp', sessionId = null, egressBase = null, store, config, log = console, spawnImpl = spawn, lookup, workerId = null, fetchImpl = globalThis.fetch }) {
this.outputId = outputId;
this.outputId = outputId;
this.workerId = workerId;
this.workerId = workerId;
this.secrets = [];
this.secrets = [];
this.destinationId = destinationId;
this.destinationId = destinationId;
this.inputUrl = inputUrl;
this.inputUrl = inputUrl;
this.source = source;
this.source = source;
+ this.sessionId = sessionId;
+ this.egressBase = egressBase;
+ this.fetchImpl = fetchImpl;
+ this.sdpPath = null;
+ this.egressHandle = null;
+ this.hasAudio = false;
this.store = store;
this.store = store;
this.config = config;
this.config = config;
this.log = log;
this.log = log;
@@ -71,13 +90,52 @@ class OutputRunner {
@@ -71,13 +90,52 @@ class OutputRunner {
if (!destUrl) return this.fail('destination has no usable URL or stream key', { cooldown: false });
if (!destUrl) return this.fail('destination has no usable URL or stream key', { cooldown: false });
this.preset = resolvePreset(dest);
this.preset = resolvePreset(dest);
this.overrides = customOverrides(dest);
this.overrides = customOverrides(dest);
+ if (this.source === 'webrtc') {
+ const prep = await this.prepareWebrtc();
+ if (this.stopped) return;
+ // No source yet (the producer has not appeared): retry with the usual backoff.
+ if (!prep.ok) return this.scheduleRestart(0, prep.error);
+ }
this.spawn(destUrl);
this.spawn(destUrl);
}
}
+ /**
+ * Open a PlainRTP egress on the worker that holds this session and write the SDP file ffmpeg
+ * reads. The consumer (and its RTP stream to the ports ffmpeg will bind) is opened once and kept
+ * across ffmpeg restarts, so only one egress consumer exists per output run.
+ */
+ async prepareWebrtc() {
+ if (this.sdpPath && this.egressHandle) return { ok: true };
+ if (!this.egressBase || !this.sessionId) return { ok: false, error: 'no WebRTC egress endpoint for this session' };
+ let res;
+ try {
+ const port = await allocateUdpPortPair();
+ const r = await this.fetchImpl(`${this.egressBase}/rtp/${this.sessionId}?vport=${port}&aport=${port + 2}`, { signal: AbortSignal.timeout(8000) });
+ if (!r.ok) { const body = await r.json().catch(() => ({})); return { ok: false, error: `WebRTC source not ready (${body.error || r.status})` }; }
+ res = await r.json();
+ } catch (err) {
+ return { ok: false, error: `WebRTC egress request failed: ${err.message}` };
+ }
+ const sdpPath = path.join(os.tmpdir(), `openre-restream-${this.outputId}.sdp`);
+ try { fs.writeFileSync(sdpPath, res.sdp, 'utf8'); } catch (err) { return { ok: false, error: `SDP not writable: ${err.message}` }; }
+ this.sdpPath = sdpPath;
+ this.egressHandle = res.handle;
+ this.hasAudio = Array.isArray(res.transports) && res.transports.length > 1;
+ return { ok: true };
+ }
+
+ async cleanupEgress() {
+ if (this.egressHandle && this.egressBase) { try { await this.fetchImpl(`${this.egressBase}/rtp/${this.sessionId}/${this.egressHandle}`, { method: 'DELETE', signal: AbortSignal.timeout(5000) }); } catch { /* best effort */ } }
+ this.egressHandle = null;
+ if (this.sdpPath) { try { fs.unlinkSync(this.sdpPath); } catch { /* gone */ } this.sdpPath = null; }
+ }
+
spawn(destUrl) {
spawn(destUrl) {
const args = withProgress(this.source === 'jsmpeg'
const args = withProgress(this.source === 'jsmpeg'
? jsmpegArgs(this.inputUrl, destUrl, this.preset || resolvePreset({}), this.overrides ? { overrides: this.overrides } : {})
? jsmpegArgs(this.inputUrl, destUrl, this.preset || resolvePreset({}), this.overrides ? { overrides: this.overrides } : {})
- : rtmpCopyArgs(this.inputUrl, destUrl));
+ : this.source === 'webrtc'
+ ? webrtcArgs(this.sdpPath, destUrl, this.preset || resolvePreset({}), { hasAudio: this.hasAudio, overrides: this.overrides || {} })
+ : rtmpCopyArgs(this.inputUrl, destUrl));
const rtmps = destUrl.startsWith('rtmps://');
const rtmps = destUrl.startsWith('rtmps://');
const bin = rtmps && this.o.ffmpegOpenSslPath && fs.existsSync(this.o.ffmpegOpenSslPath) ? this.o.ffmpegOpenSslPath : this.o.ffmpegPath;
const bin = rtmps && this.o.ffmpegOpenSslPath && fs.existsSync(this.o.ffmpegOpenSslPath) ? this.o.ffmpegOpenSslPath : this.o.ffmpegPath;
this.note('info', `starting ffmpeg → ${redactUrl(destUrl)}${bin !== this.o.ffmpegPath ? ' (openssl build)' : ''}`);
this.note('info', `starting ffmpeg → ${redactUrl(destUrl)}${bin !== this.o.ffmpegPath ? ' (openssl build)' : ''}`);
@@ -219,12 +277,14 @@ class OutputRunner {
@@ -219,12 +277,14 @@ class OutputRunner {
this.status = 'failed';
this.status = 'failed';
this.note('error', `output failed: ${message}${cooldownMinutes ? ` (destination cooling down ${cooldownMinutes} min)` : ''}`);
this.note('error', `output failed: ${message}${cooldownMinutes ? ` (destination cooling down ${cooldownMinutes} min)` : ''}`);
this.report({ state: 'failed', last_error: message, ended_at: Date.now(), next_restart_at: null, cooldown_minutes: cooldownMinutes });
this.report({ state: 'failed', last_error: message, ended_at: Date.now(), next_restart_at: null, cooldown_minutes: cooldownMinutes });
+ this.cleanupEgress().catch(() => {});
}
}
stop(reason = 'stopped') {
stop(reason = 'stopped') {
if (this.stopped && !this.proc) return;
if (this.stopped && !this.proc) return;
this.stopped = true;
this.stopped = true;
this.clearTimers();
this.clearTimers();
+ this.cleanupEgress().catch(() => {});
const proc = this.proc;
const proc = this.proc;
this.proc = null;
this.proc = null;
if (proc) {
if (proc) {
@@ -30,6 +30,8 @@ const NodeFlvSession = require('node-media-server/src/node_flv_session');
@@ -30,6 +30,8 @@ const NodeFlvSession = require('node-media-server/src/node_flv_session');
const context = require('node-media-server/src/node_core_ctx');
const context = require('node-media-server/src/node_core_ctx');
const NmsLogger = require('node-media-server/src/node_core_logger');
const NmsLogger = require('node-media-server/src/node_core_logger');
const { createWorkerRuntime } = require('./runtime');
const { createWorkerRuntime } = require('./runtime');
+const { createThumbnailer } = require('./thumbnails');
+const { createMediaClient } = require('../server/media-client');
const SESSION_PATH_RE = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})$/;
const SESSION_PATH_RE = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})$/;
@@ -63,8 +65,9 @@ function admissionChain(admission, onAdmitted, onFailed, log) {
@@ -63,8 +65,9 @@ function admissionChain(admission, onAdmitted, onFailed, log) {
return admission.then(onAdmitted, onFailed).catch((err) => log.error(`[rtmp] admission failed: ${err.stack || err}`));
return admission.then(onAdmitted, onFailed).catch((err) => log.error(`[rtmp] admission failed: ${err.stack || err}`));
}
}
-function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code) }) {
+function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code), fetchImpl, spawnImpl }) {
const { store, config } = rt;
const { store, config } = rt;
+ const thumbnails = createThumbnailer({ config, log, media: createMediaClient({ config, ...(fetchImpl ? { fetchImpl } : {}) }), ...(spawnImpl ? { spawnImpl } : {}) });
NmsLogger.setLogType(1); // errors only: the library's info lines would print stream paths
NmsLogger.setLogType(1); // errors only: the library's info lines would print stream paths
const nmsConfig = {
const nmsConfig = {
logType: 1,
logType: 1,
@@ -165,7 +168,7 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
@@ -165,7 +168,7 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
// Rename before node-media-server registers the publisher (it reads publishStreamPath
// Rename before node-media-server registers the publisher (it reads publishStreamPath
// right after this synchronous event): from here on the stream is /live/<session id>.
// right after this synchronous event): from here on the stream is /live/<session id>.
s.publishStreamPath = `/live/${adm.sessionId}`;
s.publishStreamPath = `/live/${adm.sessionId}`;
- publishers.set(id, { sessionId: adm.sessionId, definitionId: adm.definitionId, endReason: null });
+ publishers.set(id, { sessionId: adm.sessionId, definitionId: adm.definitionId, endReason: null, thumbTimer: null });
log.log(`[rtmp] session ${adm.sessionId} accepted for ${adm.definitionId} (key …${adm.hint})`);
log.log(`[rtmp] session ${adm.sessionId} accepted for ${adm.definitionId} (key …${adm.hint})`);
});
});
@@ -177,12 +180,15 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
@@ -177,12 +180,15 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
// The coordinator failed it meanwhile (lease) — the transport must not outlive its record.
// The coordinator failed it meanwhile (lease) — the transport must not outlive its record.
log.warn(`[rtmp] session ${p.sessionId} could not go live (${t.code}); closing`);
log.warn(`[rtmp] session ${p.sessionId} could not go live (${t.code}); closing`);
await stopNms(id);
await stopNms(id);
+ return;
}
}
+ startThumbnails(p);
});
});
on('donePublish', async (id) => {
on('donePublish', async (id) => {
const p = publishers.get(id);
const p = publishers.get(id);
if (!p) return;
if (!p) return;
+ if (p.thumbTimer) { clearInterval(p.thumbTimer); p.thumbTimer = null; }
publishers.delete(id);
publishers.delete(id);
await store.sessions.finish(p.sessionId, { reason: p.endReason || 'publisher_disconnected', actor: `worker:${runtime.me.id}` });
await store.sessions.finish(p.sessionId, { reason: p.endReason || 'publisher_disconnected', actor: `worker:${runtime.me.id}` });
log.log(`[rtmp] session ${p.sessionId} ended (${p.endReason || 'publisher_disconnected'})`);
log.log(`[rtmp] session ${p.sessionId} ended (${p.endReason || 'publisher_disconnected'})`);
@@ -213,6 +219,19 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
@@ -213,6 +219,19 @@ function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(cod
}
}
}
}
+ /** Live thumbnails from the loopback HTTP-FLV this worker serves (decision 4). Best effort. */
+ function startThumbnails(p) {
+ if (!thumbnails.enabled || p.thumbTimer || !endpoints.flvPort) return;
+ const flvUrl = `http://127.0.0.1:${endpoints.flvPort}/live/${p.sessionId}.flv`;
+ const grab = async () => {
+ try { const buf = await thumbnails.capture(['-i', flvUrl]); if (buf) await thumbnails.publish(store, p.sessionId, buf, { protocol: 'rtmp' }); }
+ catch (err) { log.warn(`[rtmp] thumbnail ${p.sessionId}: ${err.message}`); }
+ };
+ p.thumbTimer = setInterval(() => grab().catch(() => {}), config.webrtc.thumbnails.intervalMs);
+ p.thumbTimer.unref?.();
+ grab().catch(() => {});
+ }
+
async function endSession(sessionId, reason) {
async function endSession(sessionId, reason) {
for (const [id, p] of publishers) {
for (const [id, p] of publishers) {
if (p.sessionId === sessionId) { p.endReason = reason; await stopNms(id); return true; }
if (p.sessionId === sessionId) { p.endReason = reason; await stopNms(id); return true; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Live thumbnails for every ingest protocol (T4 decision 4). Each transport worker owns its source
+ * and grabs one frame from it every interval, then uploads it to Media as an object under the `live`
+ * namespace (grant media.object.upload ns live); the object's public URL becomes the session's
+ * thumbnail_url and travels on the session state and its openre.session.* events. Live can no longer
+ * read a local FLV/SFU/relay for OpenRe-owned sessions, so the frame has to come from here.
+ *
+ * The grabber is protocol-agnostic: the caller passes the ffmpeg *input* arguments for its source
+ * (an RTMP/HTTP-FLV URL, the JSMPEG data tap, or an SDP file for a PlainRTP consumer) and this module
+ * runs ffmpeg once, emitting one MJPEG frame on stdout. Best effort: a grab or an upload that fails is
+ * logged and the session carries on.
+ */
+const { spawn } = require('child_process');
+
+function createThumbnailer({ config, log = console, spawnImpl = spawn, media }) {
+ const cfg = config.webrtc.thumbnails;
+ const enabled = Boolean(cfg.enabled && media && media.configured);
+
+ /** One keyframe frame from ffmpeg's input args, as a JPEG buffer (or null). */
+ async function capture(inputArgs) {
+ return await new Promise((resolve) => {
+ const args = [
+ '-hide_banner', '-loglevel', 'error', '-y',
+ ...inputArgs,
+ '-frames:v', '1', '-vf', `scale=${cfg.width}:-2`,
+ '-f', 'image2pipe', '-vcodec', 'mjpeg', '-',
+ ];
+ let proc;
+ try { proc = spawnImpl(config.outputs.ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch { resolve(null); return; }
+ const chunks = [];
+ proc.stdout.on('data', (d) => chunks.push(d));
+ proc.stderr.on('data', () => {});
+ const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* gone */ } }, 15000);
+ timer.unref?.();
+ const done = (ok) => { clearTimeout(timer); const buf = Buffer.concat(chunks); resolve(ok && buf.length > 2000 ? buf : null); };
+ proc.on('error', () => done(false));
+ proc.on('close', (code) => done(code === 0));
+ });
+ }
+
+ /** Upload a grabbed buffer to Media and record the URL on the session. Returns the URL or null. */
+ async function publish(store, sessionId, buffer, metadata = {}) {
+ if (!enabled || !buffer) return null;
+ try {
+ const r = await media.uploadObject({
+ namespace: cfg.namespace,
+ kind: 'image',
+ visibility: 'public',
+ mimeType: 'image/jpeg',
+ filename: `openre-${sessionId}.jpg`,
+ bytes: buffer,
+ metadata: { openre_session_id: sessionId, ...metadata },
+ });
+ if (r && r.url) await store.sessions.setThumbnail(sessionId, r.url);
+ return r ? r.url : null;
+ } catch (err) {
+ log.warn(`[thumbnails] ${sessionId}: upload failed: ${err.message}`);
+ return null;
+ }
+ }
+
+ return { enabled, capture, publish };
+}
+
+module.exports = { createThumbnailer };
@@ -2,12 +2,526 @@
@@ -2,12 +2,526 @@
/**
/**
* openre-webrtc — the WebRTC transport worker (unit openre-webrtc@<release>.service).
* openre-webrtc — the WebRTC transport worker (unit openre-webrtc@<release>.service).
*
*
- * One worker owns WHIP ingest, the mediasoup producers/consumers and viewer signaling (T4 decision
- * 1: mediasoup is single-process, so ingest and consumption cannot be split). This replaces the
- * webrtc-ingest.js and sfu.js stubs.
+ * One worker owns everything WebRTC (T4 decision 1: mediasoup is single-process, so WHIP ingest,
+ * the SFU and viewer signaling cannot be split):
*
*
- * Not ported yet: it registers a real generation and takes part in the drain protocol, but carries
- * nothing (see unported-transport.js). The next T4 job ports it.
+ * public 0.0.0.0:OPENRE_WEBRTC_PORT (9936 until the WebRTC cutover; Live's WHIP/SFU live on its
+ * app port 3000), SO_REUSEPORT so two generations listen during a drain:
+ * POST/OPTIONS /whip/<key> WHIP ingest (RFC 9725): offer → answer
+ * PATCH/DELETE /whip/session/<id> trickle ICE / end
+ * WS upgrade /b/<key> browser broadcaster signaling (mediasoup-client)
+ * WS upgrade /w/<session id> viewer signaling, by playback id (never the key)
+ * internal 127.0.0.1:<egressPort> the RTP egress API the restream worker, Media's RTP recorder and
+ * the thumbnail grabber use: GET /rtp/<session id>?vport&aport returns the SDP a pulling
+ * ffmpeg reads (a PlainRTP consumer per producer sends RTP to those ports); DELETE
+ * /rtp/<session id>/<transport id> closes one. Port from OPENRE_WEBRTC_INTERNAL_PORT_*.
+ *
+ * Admission is exactly the other workers': resolveIngestKey with protocol 'webrtc' (one open session
+ * per definition), maxPublishersPerWorker, never while draining. A session goes live when media
+ * flows — ICE connects for WHIP (Live's whip-handler rule), the first video producer for a browser
+ * broadcaster — and ends on DELETE, ICE failure past its grace, broadcast WS close, when its last
+ * producer closes, or on the coordinator's end/drain/lost signals.
*/
*/
-if (require.main === module) require('./unported-transport').main('webrtc').catch((err) => { console.error(`[webrtc] failed to start: ${err && err.stack || err}`); process.exit(1); });
-module.exports = require('./unported-transport');
+const http = require('http');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const dgram = require('dgram');
+const { createWorkerRuntime } = require('./runtime');
+const { createSfu } = require('./webrtc/sfu');
+const sdpTool = require('./webrtc/sdp');
+const { createSignaling } = require('./webrtc/signaling');
+const { createThumbnailer } = require('./thumbnails');
+const { rtpInputArgs } = require('./restream/ffmpeg-args');
+const { createMediaClient } = require('../server/media-client');
+
+const WHIP_KEY_RE = /^\/whip\/([^/]+)\/?$/;
+const WHIP_SESSION_RE = /^\/whip\/session\/([0-9a-f]{16,})\/?$/;
+const MAX_BODY = 1 * 1024 * 1024;
+const ICE_GRACE_MS = 15000;
+const PRODUCER_GONE_GRACE_MS = 2000;
+
+const WHIP_CORS = Object.freeze({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
+ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag',
+ 'Access-Control-Max-Age': '86400',
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+});
+
+/** Bind the first free loopback port in [min, max] (skipping `skip`). */
+function listenInRange(server, min, max, skip = new Set()) {
+ return new Promise((resolve, reject) => {
+ let port = min;
+ const tryNext = () => {
+ while (skip.has(port)) port++;
+ if (port > max) { reject(new Error(`no free loopback port in ${min}-${max}`)); return; }
+ const onError = (err) => {
+ server.removeListener('listening', onListening);
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { port++; tryNext(); } else reject(err);
+ };
+ const onListening = () => { server.removeListener('error', onError); resolve(server.address().port); };
+ server.once('error', onError);
+ server.once('listening', onListening);
+ server.listen(port, '127.0.0.1');
+ };
+ tryNext();
+ });
+}
+
+/** A free UDP port (best effort: bound then released, so ffmpeg can take it and its +1 for RTCP). */
+function freeUdpPort() {
+ return new Promise((resolve, reject) => {
+ const s = dgram.createSocket('udp4');
+ s.once('error', reject);
+ s.bind(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
+ });
+}
+async function allocateUdpPortPair() {
+ const p = await freeUdpPort();
+ return p % 2 === 0 ? p : await freeUdpPort();
+}
+
+function readBody(req, limit = MAX_BODY) {
+ return new Promise((resolve, reject) => {
+ let size = 0;
+ const chunks = [];
+ req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); });
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
+ req.on('error', reject);
+ });
+}
+
+function createWebrtc({ rt, log = console, exit = (code) => process.exit(code), spawnImpl, fetchImpl = globalThis.fetch }) {
+ const { store, config } = rt;
+ const w = config.webrtc;
+
+ const sfu = createSfu({ config, log });
+ const media = createMediaClient({ config, fetchImpl });
+ const thumbnails = createThumbnailer({ config, log, ...(spawnImpl ? { spawnImpl } : {}), media });
+
+ /** sessionId → session record */
+ const sessions = new Map();
+ let pendingEnds = 0;
+ let publicServer = null;
+ let egressServer = null;
+ let endpoints = null;
+ let signaling = null;
+
+ const actor = () => `worker:${runtime.me && runtime.me.id}`;
+ function safe(promise, what) {
+ return Promise.resolve(promise).catch((err) => log.error(`[webrtc] ${what}: ${err && (err.stack || err.message) || err}`));
+ }
+
+ // ── Admission ───────────────────────────────────────────────
+ async function admit(key, mode) {
+ if (runtime.draining || !runtime.me || runtime.me.state !== 'ready') return { error: 'generation not taking sessions' };
+ if (sessions.size >= w.maxPublishersPerWorker) return { error: 'worker full' };
+ const r = await store.definitions.resolveIngestKey(key, 'webrtc');
+ if (r.error) return { error: r.error };
+ const a = await store.sessions.admit({ definition: r.definition, key: r.key, protocol: 'webrtc', worker: runtime.me });
+ if (a.error) return { error: a.error };
+ const sess = {
+ sessionId: a.session.id, definitionId: r.definition.id, hint: r.key.hint, mode,
+ ended: false, endReason: null, live: false, createdAt: Date.now(),
+ whip: new Map(), producers: new Set(), egress: new Map(),
+ thumbTimer: null, timers: new Set(),
+ };
+ sessions.set(sess.sessionId, sess);
+ return { sessionId: sess.sessionId, definitionId: sess.definitionId, hint: r.key.hint, peerId: `bc-${sess.sessionId}` };
+ }
+
+ function timer(sess, fn, ms) {
+ const t = setTimeout(() => { sess.timers.delete(t); fn(); }, ms);
+ t.unref?.();
+ sess.timers.add(t);
+ return t;
+ }
+
+ async function goLive(sess) {
+ if (sess.live || sess.ended) return;
+ sess.live = true;
+ const t = await store.sessions.transition(sess.sessionId, 'live', { reason: 'media_flowing', actor: actor() });
+ if (!t.ok) {
+ log.warn(`[webrtc] session ${sess.sessionId} could not go live (${t.code}); closing`);
+ sess.endReason = 'coordinator_failed';
+ await endSession(sess.sessionId, 'coordinator_failed');
+ }
+ }
+
+ async function endSession(sessionId, reason) {
+ const sess = sessions.get(sessionId);
+ if (sess) { sess.endReason = reason; await endSessionRecord(sess, reason); return true; }
+ pendingEnds++;
+ try { await store.sessions.finish(sessionId, { reason, actor: actor() }); } finally { pendingEnds--; }
+ if (runtime.draining) runtime.exitWhenIdle();
+ return false;
+ }
+
+ async function endSessionRecord(sess, reason) {
+ if (sess.ended) return;
+ sess.ended = true;
+ sessions.delete(sess.sessionId);
+ pendingEnds++;
+ try {
+ for (const t of sess.timers) clearTimeout(t);
+ sess.timers.clear();
+ if (sess.thumbTimer) clearInterval(sess.thumbTimer);
+ for (const e of sess.egress.values()) { for (const tid of e.transportIds) { try { sfu.closePlainConsumer(sess.sessionId, tid); } catch { /* gone */ } } }
+ sess.egress.clear();
+ sfu.closeSession(sess.sessionId);
+ await store.sessions.finish(sess.sessionId, { reason, actor: actor() });
+ log.log(`[webrtc] session ${sess.sessionId} ended (${reason})`);
+ } finally {
+ pendingEnds--;
+ }
+ if (runtime.draining) runtime.exitWhenIdle();
+ }
+
+ // ── WHIP ────────────────────────────────────────────────────
+ function whipHeaders(req, sessionId, resourceId) {
+ const host = req.headers.host || `${w.publicHost}:${w.publicPort}`;
+ return {
+ ...WHIP_CORS,
+ Location: `https://${host}/whip/session/${resourceId}`,
+ ETag: `"${resourceId}"`,
+ };
+ }
+
+ function sendWhipError(res, status, code, message) {
+ res.writeHead(status, { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code });
+ res.end(JSON.stringify({ error: message, error_code: code }));
+ }
+
+ async function handleWhipPost(req, res, key) {
+ const auth = req.headers.authorization;
+ const bearer = auth && auth.startsWith('Bearer ') ? auth.slice(7).trim() : null;
+ if (bearer && bearer !== key) return sendWhipError(res, 401, 'bearer_mismatch', 'Bearer token does not match stream key');
+ let offerText;
+ try { offerText = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Offer too large'); }
+ if (req.headers['content-type'] && /json/.test(req.headers['content-type'])) {
+ try { offerText = JSON.parse(offerText).sdp; } catch { return sendWhipError(res, 400, 'invalid_sdp', 'Invalid JSON body'); }
+ }
+ if (!offerText || typeof offerText !== 'string') return sendWhipError(res, 400, 'missing_sdp', 'Missing SDP offer in request body');
+
+ let offerSdp;
+ try { offerSdp = sdpTool.parse(offerText); } catch { return sendWhipError(res, 400, 'invalid_sdp', 'Invalid SDP offer'); }
+
+ const adm = await admit(key, 'whip');
+ if (adm.error) return sendWhipError(res, adm.error === 'worker full' ? 503 : 409, adm.error, `Publish refused: ${adm.error}`);
+ const sess = sessions.get(adm.sessionId);
+ const resourceId = crypto.randomBytes(16).toString('hex');
+ const peerId = `whip-${resourceId}`;
+
+ let transportInfo;
+ try {
+ transportInfo = await sfu.createTransport(sess.sessionId, peerId, { iceConsentTimeout: 0 });
+ } catch (err) {
+ log.warn(`[webrtc] WHIP transport failed for ${sess.sessionId}: ${err.message}`);
+ await endSessionRecord(sess, 'transport_creation_failed');
+ return sendWhipError(res, 502, 'transport_creation_failed', 'WHIP transport creation failed');
+ }
+ const transport = transportInfo.transport;
+
+ try {
+ await sfu.connectTransport(sess.sessionId, peerId, transportInfo.id, sdpTool.extractDtlsParameters(offerSdp));
+ } catch (err) {
+ log.warn(`[webrtc] WHIP DTLS failed for ${sess.sessionId}: ${err.message}`);
+ await endSessionRecord(sess, 'dtls_negotiation_failed');
+ return sendWhipError(res, 502, 'dtls_negotiation_failed', 'DTLS negotiation failed');
+ }
+
+ const routerCaps = await sfu.getRouterCapabilities(sess.sessionId);
+ const producersByKind = {};
+ for (const [mediaIndex, m] of (offerSdp.media || []).entries()) {
+ if (m.port === 0) continue;
+ let rtpParams;
+ try { rtpParams = sdpTool.extractRtpParameters(m, routerCaps, mediaIndex); } catch (err) {
+ if (err.code === 'invalid_rtp_encoding') { await endSessionRecord(sess, 'invalid_rtp_encoding'); return sendWhipError(res, 400, 'invalid_rtp_encoding', `Invalid RTP encoding for ${m.type}`); }
+ throw err;
+ }
+ if (!rtpParams || rtpParams.codecs.length === 0) continue;
+ let produced;
+ try { produced = await sfu.produce(sess.sessionId, peerId, transportInfo.id, m.type, rtpParams); } catch (err) {
+ log.warn(`[webrtc] WHIP producer failed for ${sess.sessionId}: ${err.message}`);
+ await endSessionRecord(sess, 'producer_creation_failed');
+ return sendWhipError(res, 502, 'producer_creation_failed', 'Failed to create media producer');
+ }
+ producersByKind[m.type] = produced.producer;
+ sess.producers.add(produced.id);
+ }
+ if (Object.keys(producersByKind).length === 0) {
+ await endSessionRecord(sess, 'no_compatible_codecs');
+ return sendWhipError(res, 406, 'no_compatible_codecs', 'No compatible codecs — router supports VP8, H264, Opus.');
+ }
+
+ let answer;
+ try { answer = sdpTool.buildSdpAnswer(transportInfo, offerSdp, producersByKind, { fallbackAddress: w.media.announcedIp || '127.0.0.1' }); } catch (err) {
+ log.error(`[webrtc] SDP answer failed for ${sess.sessionId}: ${err.message}`);
+ await endSessionRecord(sess, 'answer_generation_failed');
+ return sendWhipError(res, 500, 'answer_generation_failed', 'Failed to generate SDP answer');
+ }
+
+ sess.whip.set(resourceId, { peerId, transportId: transportInfo.id, iceReady: false, iceGraceTimer: null, iceFailTimer: null });
+
+ // The session goes live when media flows: ICE connected (Live's whip-handler rule).
+ transport.on('icestatechange', (state) => {
+ const entry = sess.whip.get(resourceId);
+ if (!entry) return;
+ if (state === 'failed') { clearTimeout(entry.iceFailTimer); safe(endSession(sess.sessionId, 'ice_failed'), 'ice failed end'); return; }
+ if (state === 'disconnected') {
+ clearTimeout(entry.iceGraceTimer);
+ entry.iceGraceTimer = setTimeout(() => { if (sess.whip.has(resourceId) && !sess.live) safe(endSession(sess.sessionId, 'ice_disconnected'), 'ice grace end'); }, ICE_GRACE_MS);
+ entry.iceGraceTimer.unref?.();
+ return;
+ }
+ if (state === 'connected' || state === 'completed') {
+ clearTimeout(entry.iceGraceTimer);
+ if (!entry.iceReady) { entry.iceReady = true; safe(goLive(sess), 'go live'); }
+ }
+ });
+ transport.on('dtlsstatechange', (state) => {
+ if ((state === 'closed' || state === 'failed') && sess.whip.has(resourceId)) safe(endSession(sess.sessionId, 'dtls_closed'), 'dtls end');
+ });
+
+ log.log(`[webrtc] WHIP session ${sess.sessionId} accepted for ${sess.definitionId} (key …${sess.hint}), ${Object.keys(producersByKind).length} producer(s)`);
+ res.writeHead(201, { 'content-type': 'application/sdp', ...whipHeaders(req, sess.sessionId, resourceId) });
+ res.end(answer);
+ }
+
+ function handleWhipPatch(req, res, resourceId) {
+ let found = null;
+ for (const sess of sessions.values()) if (sess.whip.has(resourceId)) { found = sess; break; }
+ if (!found) return sendWhipError(res, 404, 'session_not_found', 'Session not found');
+ readBody(req).then((body) => { sdpTool.countCandidates(body); res.writeHead(204, WHIP_CORS); res.end(); }).catch(() => { res.writeHead(204, WHIP_CORS); res.end(); });
+ }
+
+ async function handleWhipDelete(req, res, resourceId) {
+ let found = null;
+ for (const sess of sessions.values()) if (sess.whip.has(resourceId)) { found = sess; break; }
+ if (found) await endSession(found.sessionId, 'whip_delete');
+ res.writeHead(200, WHIP_CORS);
+ res.end();
+ }
+
+ // ── RTP egress (loopback) ───────────────────────────────────
+ async function describeEgress(sess) {
+ const caps = await sfu.getRouterCapabilities(sess.sessionId);
+ const out = { video: null, audio: null };
+ for (const kind of ['video', 'audio']) {
+ const p = sfu.findProducerByKind(sess.sessionId, kind);
+ if (!p) continue;
+ const prod = sfu.getProducers(sess.sessionId).find(x => x.id === p.id);
+ if (prod && prod.paused) continue;
+ const routerCodec = caps.codecs.find(c => c.kind === kind && (kind !== 'video' || c.mimeType === 'video/VP8')) || caps.codecs.find(c => c.kind === kind);
+ out[kind] = routerCodec ? {
+ payloadType: routerCodec.preferredPayloadType,
+ codec: (routerCodec.mimeType || `${kind}/unknown`).split('/')[1],
+ clockRate: routerCodec.clockRate,
+ ...(kind === 'audio' && routerCodec.channels ? { channels: routerCodec.channels } : {}),
+ } : null;
+ }
+ return out;
+ }
+
+ async function openEgress(sess, { vport, aport }) {
+ const videoProducer = sfu.findProducerByKind(sess.sessionId, 'video');
+ if (!videoProducer) return { error: 'no video producer yet' };
+ const video = await sfu.createPlainConsumer(sess.sessionId, videoProducer.id, { remoteIp: '127.0.0.1', remotePort: vport, remoteRtcpPort: vport + 1 });
+ const audioProducer = sfu.findProducerByKind(sess.sessionId, 'audio');
+ let audio = null;
+ if (audioProducer && aport) audio = await sfu.createPlainConsumer(sess.sessionId, audioProducer.id, { remoteIp: '127.0.0.1', remotePort: aport, remoteRtcpPort: aport + 1 });
+ const transportIds = [video.transportId, ...(audio ? [audio.transportId] : [])];
+ const sdp = sdpTool.buildEgressSdp({ video: { ...video, port: vport }, audio: audio ? { ...audio, port: aport } : null });
+ return { sdp, transports: transportIds, video, audio };
+ }
+
+ function handleEgressHttp(req, res) {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ const json = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ const get = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/?$/.exec(url.pathname);
+ const describe = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/describe$/.exec(url.pathname);
+ const del = /^\/rtp\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/([a-f0-9]+)\/?$/.exec(url.pathname);
+ safe((async () => {
+ if (req.method === 'GET' && describe) {
+ const sess = sessions.get(describe[1]);
+ if (!sess) return json(404, { error: 'session_not_found' });
+ return json(200, await describeEgress(sess));
+ }
+ if (req.method === 'GET' && get) {
+ const sess = sessions.get(get[1]);
+ if (!sess) return json(404, { error: 'session_not_found' });
+ const vport = Number(url.searchParams.get('vport'));
+ const aport = Number(url.searchParams.get('aport')) || 0;
+ if (!Number.isInteger(vport) || vport <= 0) return json(400, { error: 'vport required' });
+ try {
+ const r = await openEgress(sess, { vport, aport });
+ if (r.error) return json(404, { error: r.error });
+ const handle = crypto.randomBytes(8).toString('hex');
+ sess.egress.set(handle, { transportIds: r.transports, at: Date.now() });
+ return json(200, { handle, sdp: r.sdp, transports: r.transports });
+ } catch (err) { return json(502, { error: err.message }); }
+ }
+ if (req.method === 'DELETE' && del) {
+ const sess = sessions.get(del[1]);
+ if (!sess) return json(404, { error: 'session_not_found' });
+ const handle = del[2];
+ const e = sess.egress.get(handle);
+ if (e) { for (const tid of e.transportIds) { try { sfu.closePlainConsumer(sess.sessionId, tid); } catch { /* gone */ } } sess.egress.delete(handle); }
+ return json(200, { ok: true });
+ }
+ return json(404, { error: 'not_found' });
+ })(), 'egress request');
+ }
+
+ // ── Thumbnails ──────────────────────────────────────────────
+ function startThumbnails(sess) {
+ if (!thumbnails.enabled || sess.thumbTimer) return;
+ const grab = async () => {
+ if (sess.ended) return;
+ try {
+ const producer = sfu.findProducerByKind(sess.sessionId, 'video');
+ if (!producer) return;
+ const port = await allocateUdpPortPair();
+ const video = await sfu.createPlainConsumer(sess.sessionId, producer.id, { remoteIp: '127.0.0.1', remotePort: port, remoteRtcpPort: port + 1 });
+ const sdpPath = path.join(os.tmpdir(), `openre-thumb-${sess.sessionId}-${video.transportId}.sdp`);
+ fs.writeFileSync(sdpPath, sdpTool.buildEgressSdp({ video: { ...video, port } }), 'utf8');
+ let buffer = null;
+ try { buffer = await thumbnails.capture(rtpInputArgs(sdpPath)); } finally {
+ try { sfu.closePlainConsumer(sess.sessionId, video.transportId); } catch { /* gone */ }
+ try { fs.unlinkSync(sdpPath); } catch { /* gone */ }
+ }
+ if (buffer) await thumbnails.publish(store, sess.sessionId, buffer, { protocol: 'webrtc' });
+ } catch (err) { log.warn(`[webrtc] thumbnail ${sess.sessionId}: ${err.message}`); }
+ };
+ sess.thumbTimer = setInterval(() => safe(grab(), 'thumbnail'), w.thumbnails.intervalMs);
+ sess.thumbTimer.unref?.();
+ safe(grab(), 'thumbnail');
+ }
+
+ // ── HTTP servers ────────────────────────────────────────────
+ function handleRequest(req, res) {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ if (url.pathname === '/whip' || url.pathname.startsWith('/whip/')) {
+ if (req.method === 'OPTIONS') { res.writeHead(204, WHIP_CORS); return res.end(); }
+ const kMatch = WHIP_KEY_RE.exec(url.pathname);
+ const sMatch = WHIP_SESSION_RE.exec(url.pathname);
+ if (req.method === 'POST' && kMatch) return safe(handleWhipPost(req, res, decodeURIComponent(kMatch[1])), 'whip post');
+ if (req.method === 'PATCH' && sMatch) return handleWhipPatch(req, res, sMatch[1]);
+ if (req.method === 'DELETE' && sMatch) return safe(handleWhipDelete(req, res, sMatch[1]), 'whip delete');
+ res.writeHead(404, WHIP_CORS);
+ return res.end();
+ }
+ res.writeHead(404);
+ return res.end();
+ }
+
+ async function closePublic() {
+ if (!publicServer) return;
+ publicServer.close();
+ log.log('[webrtc] public listener closed; running sessions stay until they leave');
+ }
+
+ function closeAll() {
+ for (const sess of [...sessions.values()]) { try { sfu.closeSession(sess.sessionId); } catch { /* gone */ } }
+ closePublic();
+ if (egressServer) { egressServer.closeAllConnections?.(); egressServer.close(); egressServer = null; }
+ if (signaling) signaling.closeAll();
+ sfu.closeAll();
+ const closing = [];
+ if (publicServer) { publicServer.closeAllConnections?.(); closing.push(new Promise(r => publicServer.close(() => r()))); publicServer = null; }
+ return Promise.all(closing);
+ }
+
+ const runtime = createWorkerRuntime({
+ rt, kind: 'webrtc', log, exit,
+ hooks: {
+ onDrain: () => closePublic(),
+ onDrainDeadline: async () => { for (const s of [...sessions.values()]) await endSessionRecord(s, 'drain_deadline'); },
+ onEndRequested: async (s) => await endSession(s.id, 'end_requested'),
+ onLost: async () => { for (const s of [...sessions.values()]) await endSessionRecord(s, 'worker_lost'); return closeAll(); },
+ onHeartbeat: async () => { for (const sess of sessions.values()) await store.sessions.setViewers(sess.sessionId, sfu.getViewerCount(sess.sessionId)); },
+ activeCount: () => sessions.size + pendingEnds,
+ onExit: () => closeAll(),
+ },
+ });
+
+ // A session with no producers left after having been live is over (its encoder disconnected).
+ sfu.on('producer-removed', ({ sessionId }) => {
+ const sess = sessions.get(sessionId);
+ if (!sess || sess.ended) return;
+ if (sfu.hasProducers(sessionId)) return;
+ timer(sess, () => { if (!sess.ended && !sfu.hasProducers(sessionId) && sess.live) safe(endSession(sessionId, 'producer_closed'), 'producer gone end'); }, PRODUCER_GONE_GRACE_MS);
+ });
+ // A *browser* broadcaster goes live when its first video producer appears (Live's
+ // broadcast-server rule). A WHIP publisher is different: media can be produced before ICE/DTLS
+ // completes, so it only goes live when ICE connects (Live's whip-handler rule, below).
+ sfu.on('producer-added', ({ sessionId, kind }) => {
+ const sess = sessions.get(sessionId);
+ if (sess && sess.mode === 'browser' && kind === 'video') safe(goLive(sess), 'go live');
+ });
+
+ signaling = createSignaling({
+ sfu,
+ iceServers: w.stunUrls,
+ log,
+ admit: async (key) => {
+ const r = await admit(key, 'browser');
+ if (r.error) return { error: r.error };
+ const sess = sessions.get(r.sessionId);
+ startThumbnails(sess);
+ return { sessionId: r.sessionId, peerId: r.peerId };
+ },
+ endSession: async (sessionId, reason) => await endSession(sessionId, reason),
+ });
+
+ async function start() {
+ if (!sfu.available) throw new Error('mediasoup is not installed (add mediasoup to package.json and npm install)');
+ if (!sdpTool.available()) throw new Error('sdp-transform is not installed (add sdp-transform to package.json and npm install)');
+ if (config.isProduction && !w.media.announcedIp) throw new Error('MEDIASOUP_ANNOUNCED_IP is required in production (the public address candidates are advertised with)');
+ await sfu.init();
+ sfu.setAnnouncedIp(w.media.announcedIp);
+
+ // Internal egress API first: it is how this generation is reached.
+ egressServer = http.createServer((req, res) => handleEgressHttp(req, res));
+ const egressPort = await listenInRange(egressServer, w.internalPortMin, w.internalPortMax);
+ endpoints = { publicPort: w.port, publicPorts: [w.port], egressPort, announcedIp: w.media.announcedIp || null };
+ await runtime.register(endpoints);
+
+ publicServer = http.createServer((req, res) => handleRequest(req, res));
+ publicServer.on('upgrade', (req, socket, head) => { safe((async () => { if (await signaling.handleUpgrade(req, socket, head)) return; socket.destroy(); })(), 'upgrade'); });
+ await new Promise((resolve, reject) => {
+ publicServer.once('error', reject);
+ publicServer.listen({ port: w.port, host: w.bindHost, reusePort: true }, () => resolve());
+ });
+ publicServer.on('error', (err) => log.error(`[webrtc] public listener ${w.port}: ${err.message}`));
+ await runtime.ready();
+ log.log(`[webrtc] generation ${runtime.me.generation} ready: whip+signaling ${w.bindHost}:${w.port}, egress 127.0.0.1:${egressPort}`);
+ return endpoints;
+ }
+
+ return {
+ start, runtime, sfu, endpoints: () => endpoints,
+ sessions: () => [...sessions.values()],
+ drain: async (reason) => await runtime.drainNow(reason),
+ close: closeAll,
+ };
+}
+
+module.exports = { createWebrtc, listenInRange };
+
+if (require.main === module) {
+ (async () => {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ const { load, exitIfDrill } = require('../server/config');
+ const { openRuntime } = require('../server/store');
+ const config = load();
+ exitIfDrill(config, 'openre-webrtc');
+ const rt = await openRuntime({ config });
+ const worker = createWebrtc({ rt });
+ await worker.start();
+ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig).catch((err) => console.error(`[webrtc] drain failed on ${sig}: ${err && (err.stack || err.message) || err}`)));
+ })().catch((err) => { console.error(`[webrtc] failed to start: ${err && err.stack || err}`); process.exit(1); });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
+'use strict';
+/**
+ * WHIP SDP <-> mediasoup bridging, ported from OpenVibe.Live server/streaming/whip-handler.js
+ * (offer validation, DTLS parameter extraction, RTP parameter extraction against the router's
+ * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRe
+ * validates the offer, negotiates codecs and builds the answer, and nothing here touches a database.
+ *
+ * `sdp-transform` is required lazily so the module (and the whole worker) can be loaded — and its
+ * string-only helpers tested — on a host where the dependency is not installed yet; `available()`
+ * says whether parsing/building is possible at all, which is what the worker checks at start.
+ */
+let sdpTransform = null;
+
+function load() {
+ if (!sdpTransform) sdpTransform = require('sdp-transform');
+ return sdpTransform;
+}
+
+function available() {
+ try { load(); return true; } catch { return false; }
+}
+
+function parse(sdp) {
+ return load().parse(sdp);
+}
+
+function write(obj) {
+ return load().write(obj);
+}
+
+/** The DTLS setup attribute of the offer (top-level or first media section that has one). */
+function getDtlsSetupAttribute(sdpObj) {
+ if (sdpObj.setup) return String(sdpObj.setup).toLowerCase();
+ for (const media of sdpObj.media || []) if (media.setup) return String(media.setup).toLowerCase();
+ return null;
+}
+
+/** sha-256 is preferred over sha-1, exactly as Live's WHIP handler picks it. */
+function selectDtlsFingerprint(fingerprints) {
+ if (!Array.isArray(fingerprints) || fingerprints.length === 0) throw new Error('No DTLS fingerprints available');
+ const normalized = fingerprints.map(fp => ({ algorithm: String(fp.algorithm || '').toLowerCase(), fingerprint: fp }));
+ for (const algorithm of ['sha-256', 'sha-1']) {
+ const match = normalized.find(item => item.algorithm === algorithm);
+ if (match) return match.fingerprint;
+ }
+ return fingerprints[0];
+}
+
+/** transport.connect()'s remote role: an offer with setup=passive means the remote side is the DTLS server. */
+function extractDtlsParameters(sdpObj) {
+ let fingerprint = sdpObj.fingerprint;
+ const setup = getDtlsSetupAttribute(sdpObj);
+ for (const media of sdpObj.media || []) if (!fingerprint && media.fingerprint) fingerprint = media.fingerprint;
+ if (!fingerprint) throw new Error('No DTLS fingerprint in SDP');
+ return {
+ role: setup === 'passive' ? 'server' : 'client',
+ fingerprints: [{ algorithm: fingerprint.type, value: fingerprint.hash }],
+ };
+}
+
+function h264ProfileIdc(profileLevelId) {
+ const profile = String(profileLevelId || '').trim().toLowerCase();
+ if (!/^[0-9a-f]+$/.test(profile) || profile.length < 2) return null;
+ return profile.slice(0, 2);
+}
+
+function normalizeH264ProfileLevelId(offeredParams, routerParams) {
+ const off = h264ProfileIdc(offeredParams && offeredParams['profile-level-id']);
+ const rc = h264ProfileIdc(routerParams && routerParams['profile-level-id']);
+ if (!off || !rc || off !== rc) return false;
+ if (routerParams && routerParams['profile-level-id']) offeredParams['profile-level-id'] = routerParams['profile-level-id'];
+ return true;
+}
+
+/**
+ * RTP parameters for one media section, codecs matched against the router's capabilities. Throws
+ * { code: 'invalid_rtp_encoding' } when the section names no usable encoding (Live does the same).
+ */
+function extractRtpParameters(media, routerCapabilities, mediaIndex = 0) {
+ if (!media || !media.type) return null;
+ const routerCodecs = routerCapabilities.codecs || [];
+ const codecs = [];
+ const headerExtensions = [];
+ const encodings = [];
+
+ const rtpmaps = {};
+ for (const rtp of media.rtp || []) {
+ rtpmaps[rtp.payload] = {
+ mimeType: `${media.type}/${rtp.codec}`,
+ payloadType: rtp.payload,
+ clockRate: rtp.rate,
+ channels: rtp.encoding || undefined,
+ parameters: {},
+ rtcpFeedback: [],
+ };
+ }
+ for (const fmtp of media.fmtp || []) {
+ const target = rtpmaps[fmtp.payload];
+ if (!target || !fmtp.config) continue;
+ const params = {};
+ for (const part of fmtp.config.split(';')) {
+ const trimmed = part.trim();
+ if (!trimmed) continue;
+ const eqIdx = trimmed.indexOf('=');
+ if (eqIdx > 0) {
+ const key = trimmed.slice(0, eqIdx).trim();
+ const val = trimmed.slice(eqIdx + 1).trim();
+ if (key === 'profile-level-id' || key === 'sprop-parameter-sets' || key === 'level-asymmetry-allowed') params[key] = val;
+ else params[key] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
+ }
+ }
+ target.parameters = params;
+ }
+ for (const fb of media.rtcpFb || []) {
+ if (rtpmaps[fb.payload]) rtpmaps[fb.payload].rtcpFeedback.push({ type: fb.type, parameter: fb.subtype || '' });
+ }
+
+ const payloads = (media.payloads || '').toString().split(' ').map(Number).filter(n => !isNaN(n));
+ let primaryCodec = null;
+ for (const pt of payloads) {
+ const offered = rtpmaps[pt];
+ if (!offered) continue;
+ if (offered.mimeType.toLowerCase().endsWith('/rtx')) continue;
+ const match = routerCodecs.find(rc => {
+ if (rc.mimeType.toLowerCase() !== offered.mimeType.toLowerCase()) return false;
+ if (rc.clockRate !== offered.clockRate) return false;
+ if (offered.channels && rc.channels && offered.channels !== rc.channels) return false;
+ if (rc.mimeType.toLowerCase() === 'video/h264') {
+ const rcIdc = h264ProfileIdc(rc.parameters && rc.parameters['profile-level-id']);
+ const offIdc = h264ProfileIdc(offered.parameters && offered.parameters['profile-level-id']);
+ if (rcIdc && offIdc && rcIdc !== offIdc) return false;
+ if (rcIdc && offIdc) normalizeH264ProfileLevelId(offered.parameters, rc.parameters || {});
+ }
+ return true;
+ });
+ if (match) { primaryCodec = offered; codecs.push(offered); break; }
+ }
+ if (!primaryCodec) return null;
+
+ for (const pt of payloads) {
+ const offered = rtpmaps[pt];
+ if (!offered) continue;
+ if (offered.mimeType.toLowerCase() === `${media.type}/rtx` && offered.parameters && offered.parameters.apt === primaryCodec.payloadType) { codecs.push(offered); break; }
+ }
+
+ for (const ext of media.ext || []) {
+ const routerExt = (routerCapabilities.headerExtensions || []).find(re => re.uri === ext.uri && (re.kind === media.type || !re.kind));
+ if (routerExt) headerExtensions.push({ uri: ext.uri, id: ext.value, encrypt: false, parameters: {} });
+ }
+
+ const ssrcEntries = Array.isArray(media.ssrcs) ? media.ssrcs.filter(Boolean) : (media.ssrc ? [media.ssrc] : []);
+ const normalizeSsrc = (v) => (v === undefined || v === null ? null : (Number.isFinite(Number(v)) ? Number(v) : String(v)));
+ const mainSsrc = (ssrcEntries.find(s => s.attribute === 'cname') || ssrcEntries[0] || {}).id;
+ let encoding = null;
+ if (mainSsrc) {
+ encoding = { ssrc: normalizeSsrc(mainSsrc) };
+ const fidGroup = (media.ssrcGroups || []).find(g => g.semantics === 'FID' || g.semantics === 'fid');
+ if (fidGroup && typeof fidGroup.ssrcs === 'string') {
+ const parts = fidGroup.ssrcs.split(' ').map(s => s.trim()).filter(Boolean);
+ if (parts.length >= 2 && String(normalizeSsrc(parts[0])) === String(encoding.ssrc)) encoding.rtx = { ssrc: normalizeSsrc(parts[1]) };
+ }
+ } else if (Array.isArray(media.rids) && media.rids.length > 0) {
+ const ridValue = media.rids[0].id || media.rids[0];
+ if (ridValue) encoding = { rid: String(ridValue) };
+ } else if (media.rid) {
+ encoding = { rid: String(media.rid) };
+ }
+ if (!encoding) {
+ const error = new Error('No RTP encoding found in SDP media section');
+ error.code = 'invalid_rtp_encoding';
+ error.mediaType = media.type;
+ error.mid = media.mid != null ? String(media.mid) : String(mediaIndex);
+ throw error;
+ }
+ encodings.push(encoding);
+
+ return {
+ codecs,
+ headerExtensions,
+ encodings,
+ mid: media.mid != null ? String(media.mid) : String(mediaIndex),
+ };
+}
+
+/**
+ * Build the SDP answer for a WHIP ingest (server recvonly). Only *accepted* m-sections go in the
+ * BUNDLE group (RFC 8843 §7.3.3), so a browser publisher whose extra section (a data channel, a
+ * codec the router lacks) is rejected is not refused by its own SDP parser.
+ */
+function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRe.Stream', fallbackAddress = '127.0.0.1' } = {}) {
+ const { iceParameters, iceCandidates, dtlsParameters } = transport;
+ const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
+ const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
+ const bundledMids = [];
+ const serverAddress = (iceCandidates && iceCandidates[0] && iceCandidates[0].ip) || fallbackAddress;
+
+ const sdpObj = {
+ version: 0,
+ origin: { username: '-', sessionId: String(Date.now()), sessionVersion: 2, netType: 'IN', ipVer: 4, address: serverAddress },
+ name: serverName,
+ timing: { start: 0, stop: 0 },
+ icelite: 'ice-lite',
+ groups: [],
+ msidSemantic: { semantic: 'WMS', token: '*' },
+ media: [],
+ };
+
+ for (const [mediaIndex, offerMedia] of (offerSdp.media || []).entries()) {
+ const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mediaIndex);
+ const producer = producersByKind[offerMedia.type];
+ if (!producer) {
+ sdpObj.media.push({
+ type: offerMedia.type,
+ port: 0,
+ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
+ payloads: String(offerMedia.payloads || '0'),
+ mid,
+ direction: 'inactive',
+ });
+ continue;
+ }
+ bundledMids.push(mid);
+ const answerMedia = {
+ type: offerMedia.type,
+ port: 9,
+ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
+ payloads: '',
+ connection: { ip: '0.0.0.0', version: 4 },
+ mid,
+ iceUfrag: iceParameters.usernameFragment,
+ icePwd: iceParameters.password,
+ fingerprint: { type: fingerprint.algorithm, hash: fingerprint.value },
+ setup,
+ direction: 'recvonly',
+ rtcpMux: 'rtcp-mux',
+ rtp: [],
+ fmtp: [],
+ rtcpFb: [],
+ ext: [],
+ candidates: (iceCandidates || []).map(c => {
+ const cand = { foundation: c.foundation, component: 1, transport: c.protocol.toUpperCase(), priority: c.priority, ip: c.ip, port: c.port, type: c.type };
+ if (c.tcpType) cand.tcptype = c.tcpType;
+ return cand;
+ }),
+ };
+ const pts = [];
+ for (const codec of producer.rtpParameters.codecs) {
+ pts.push(codec.payloadType);
+ answerMedia.rtp.push({ payload: codec.payloadType, codec: codec.mimeType.split('/')[1], rate: codec.clockRate, encoding: codec.channels });
+ if (codec.parameters && Object.keys(codec.parameters).length > 0) {
+ answerMedia.fmtp.push({ payload: codec.payloadType, config: Object.entries(codec.parameters).map(([k, v]) => `${k}=${v}`).join(';') });
+ }
+ for (const fb of codec.rtcpFeedback || []) answerMedia.rtcpFb.push({ payload: codec.payloadType, type: fb.type, subtype: fb.parameter || undefined });
+ }
+ answerMedia.payloads = pts.join(' ');
+ for (const ext of producer.rtpParameters.headerExtensions || []) answerMedia.ext.push({ value: ext.id, uri: ext.uri });
+ sdpObj.media.push(answerMedia);
+ }
+
+ if (bundledMids.length > 0) sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });
+ return write(sdpObj);
+}
+
+/** How many ICE candidates a trickle-PATCH body carries (used only for logging). */
+function countCandidates(sdpText) {
+ if (typeof sdpText !== 'string' || !sdpText.trim()) return 0;
+ try {
+ const parsed = parse(sdpText);
+ return Array.isArray(parsed.media) ? parsed.media.reduce((sum, m) => sum + (Array.isArray(m.candidates) ? m.candidates.length : 0), 0) : 0;
+ } catch {
+ return 0;
+ }
+}
+
+/**
+ * The SDP a pulling ffmpeg reads for a PlainRTP egress (restream, Media's RTP recorder, thumbnails),
+ * ported from Live's RestreamManager._buildSdp. `port` is the local UDP port the *consumer* (ffmpeg)
+ * listens on; the worker's PlainRTP consumer is already sending to it.
+ */
+function buildEgressSdp({ video, audio } = {}) {
+ const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRe.Stream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];
+ const section = (type, c, port) => {
+ const pt = c.payloadType;
+ const codecName = (c.mimeType || `${type}/VP8`).split('/')[1];
+ if (type === 'audio') lines.push(`m=audio ${port} RTP/AVP ${pt}`);
+ else lines.push(`m=video ${port} RTP/AVP ${pt}`);
+ lines.push(`a=rtpmap:${pt} ${codecName}/${c.clockRate}${type === 'audio' ? `/${c.channels || 2}` : ''}`);
+ if (c.ssrc) lines.push(`a=ssrc:${c.ssrc} cname:openre-${type}`);
+ if (c.codecParameters && Object.keys(c.codecParameters).length) lines.push(`a=fmtp:${pt} ${Object.entries(c.codecParameters).map(([k, v]) => `${k}=${v}`).join(';')}`);
+ lines.push('a=recvonly');
+ };
+ if (video) section('video', video, video.port);
+ if (audio) section('audio', audio, audio.port);
+ lines.push('');
+ return lines.join('\r\n');
+}
+
+module.exports = {
+ available, parse, write,
+ getDtlsSetupAttribute, selectDtlsFingerprint, extractDtlsParameters, extractRtpParameters, buildSdpAnswer, countCandidates, buildEgressSdp,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+'use strict';
+/**
+ * The mediasoup SFU, one Router per OpenRe session, ported from OpenVibe.Live
+ * server/streaming/webrtc-sfu.js. The differences that matter:
+ *
+ * - a "room" is an OpenRe ingest session id (not Live's `stream-<id>`), so a router's lifetime
+ * is exactly the session's;
+ * - consumer/producer/transport bookkeeping is unchanged, so the viewer signaling and the
+ * PlainRTP egress paths behave like Live's;
+ * - PlainRTP consumers (restream, Media's RTP recording, thumbnails) send to an explicit remote
+ * ip:port the caller chose, because on OpenRe the consumer side (ffmpeg) runs in another process.
+ *
+ * `mediasoup` is required lazily: the module loads (and its turn-free helpers run) on a host where
+ * the native dependency is not installed, and `createSfu().available` is false there. The worker
+ * refuses to start in that case rather than register a generation that can never take a session.
+ */
+const { EventEmitter } = require('events');
+
+function createSfu({ config, log = console }) {
+ let mediasoup = null;
+ try { mediasoup = require('mediasoup'); } catch { mediasoup = null; }
+
+ const media = config.webrtc.media;
+ const emitter = new EventEmitter();
+ const workers = [];
+ let rr = 0;
+ /** sessionId → { router, producers: Map<id,{producer,peerId,transportId}>, consumers: Map<id,{consumer,peerId}>, transports: Map<key,transport> } */
+ const routers = new Map();
+ let announcedIp = '';
+
+ async function init() {
+ if (!mediasoup) throw new Error('mediasoup is not installed (add it to package.json and npm install)');
+ if (workers.length) return workers[0];
+ for (let i = 0; i < media.workers; i++) {
+ const w = await mediasoup.createWorker({
+ logLevel: 'warn',
+ rtcMinPort: media.minPort,
+ rtcMaxPort: media.maxPort,
+ });
+ w.on('died', () => {
+ log.error(`[webrtc] mediasoup worker ${w.pid} died`);
+ try { w.close(); } catch { /* already gone */ }
+ emitter.emit('worker-died', w);
+ });
+ workers.push(w);
+ }
+ log.log(`[webrtc] mediasoup: ${workers.length} worker(s), RTC UDP ${media.minPort}-${media.maxPort}`);
+ return workers[0];
+ }
+
+ function setAnnouncedIp(ip) { announcedIp = ip || ''; }
+
+ function pickWorker() {
+ if (!workers.length) throw new Error('SFU not initialised');
+ return workers[rr++ % workers.length];
+ }
+
+ async function getOrCreateRouter(sessionId) {
+ if (routers.has(sessionId)) return routers.get(sessionId);
+ if (!workers.length) throw new Error('SFU not initialised');
+ const router = await pickWorker().createRouter({ mediaCodecs: media.mediaCodecs });
+ const room = { router, producers: new Map(), consumers: new Map(), transports: new Map() };
+ routers.set(sessionId, room);
+ return room;
+ }
+
+ function room(sessionId) { return routers.get(sessionId) || null; }
+
+ function hasRouter(sessionId) { return routers.has(sessionId); }
+
+ async function getRouterCapabilities(sessionId) {
+ return (await getOrCreateRouter(sessionId)).router.rtpCapabilities;
+ }
+
+ const listenIps = () => [{ ip: media.listenIp, ...(announcedIp ? { announcedIp } : {}) }];
+
+ async function createTransport(sessionId, peerId, options = {}) {
+ const r = await getOrCreateRouter(sessionId);
+ const transportOptions = {
+ listenIps: listenIps(),
+ enableUdp: true,
+ enableTcp: true,
+ preferUdp: true,
+ initialAvailableOutgoingBitrate: 3000000,
+ };
+ // OBS/libdatachannel WHIP clients do not answer RFC 7675 consent requests; Live disables the
+ // consent timeout for them the same way.
+ if (typeof options.iceConsentTimeout === 'number') transportOptions.iceConsentTimeout = options.iceConsentTimeout;
+ const transport = await r.router.createWebRtcTransport(transportOptions);
+ transport.on('dtlsstatechange', (state) => {
+ if (state === 'closed' || state === 'failed') { try { transport.close(); } catch { /* gone */ } }
+ });
+ r.transports.set(`${peerId}-${transport.id}`, transport);
+ return {
+ id: transport.id,
+ iceParameters: transport.iceParameters,
+ iceCandidates: transport.iceCandidates,
+ dtlsParameters: transport.dtlsParameters,
+ transport,
+ };
+ }
+
+ function lookupTransport(sessionId, peerId, transportId) {
+ const r = room(sessionId);
+ return r ? r.transports.get(`${peerId}-${transportId}`) : null;
+ }
+
+ async function connectTransport(sessionId, peerId, transportId, dtlsParameters) {
+ const transport = lookupTransport(sessionId, peerId, transportId);
+ if (!transport) throw new Error('Transport not found');
+ await transport.connect({ dtlsParameters });
+ return transport;
+ }
+
+ async function produce(sessionId, peerId, transportId, kind, rtpParameters) {
+ const r = room(sessionId);
+ if (!r) throw new Error('Router not found');
+ const transport = r.transports.get(`${peerId}-${transportId}`);
+ if (!transport) throw new Error('Transport not found');
+ const producer = await transport.produce({ kind, rtpParameters });
+ r.producers.set(producer.id, { producer, peerId, transportId });
+ producer.on('transportclose', () => { r.producers.delete(producer.id); emitter.emit('producer-removed', { sessionId, producerId: producer.id, kind }); });
+ emitter.emit('producer-added', { sessionId, producerId: producer.id, kind, peerId });
+ return { id: producer.id, producer };
+ }
+
+ async function consume(sessionId, peerId, transportId, producerId, rtpCapabilities) {
+ const r = room(sessionId);
+ if (!r) throw new Error('Router not found');
+ if (!r.router.canConsume({ producerId, rtpCapabilities })) throw new Error('Cannot consume this producer');
+ const transport = r.transports.get(`${peerId}-${transportId}`);
+ if (!transport) throw new Error('Transport not found');
+ const consumer = await transport.consume({ producerId, rtpCapabilities, paused: false });
+ r.consumers.set(consumer.id, { consumer, peerId });
+ consumer.on('transportclose', () => { r.consumers.delete(consumer.id); });
+ consumer.on('producerclose', () => {
+ try { consumer.close(); } catch { /* gone */ }
+ r.consumers.delete(consumer.id);
+ emitter.emit('consumer-closed', { sessionId, peerId, consumerId: consumer.id, kind: consumer.kind });
+ });
+ if (consumer.kind === 'video') {
+ // A late viewer must not wait for the source's GOP boundary: nudge it a few times.
+ for (const d of [0, 400, 1200]) setTimeout(() => { consumer.requestKeyFrame().catch(() => {}); }, d).unref?.();
+ }
+ return {
+ id: consumer.id, producerId: consumer.producerId, kind: consumer.kind,
+ rtpParameters: consumer.rtpParameters, consumer,
+ };
+ }
+
+ /**
+ * A PlainRTP consumer that sends a producer's RTP to an explicit remote ip:port pair (the
+ * restream worker's ffmpeg, Media's RTP ingest, or the thumbnail grabber). Returns the codec
+ * facts the caller needs to write an SDP.
+ */
+ async function createPlainConsumer(sessionId, producerId, { remoteIp = '127.0.0.1', remotePort, remoteRtcpPort }) {
+ const r = room(sessionId);
+ if (!r) throw new Error('Router not found');
+ const transport = await r.router.createPlainTransport({ listenIp: { ip: '127.0.0.1' }, rtcpMux: false, comedia: false });
+ try {
+ await transport.connect({ ip: remoteIp, port: remotePort, rtcpPort: remoteRtcpPort });
+ } catch (err) {
+ try { transport.close(); } catch { /* gone */ }
+ throw err;
+ }
+ const consumer = await transport.consume({ producerId, rtpCapabilities: r.router.rtpCapabilities, paused: false });
+ const key = `plain-${transport.id}`;
+ r.transports.set(key, transport);
+ r.consumers.set(consumer.id, { consumer, peerId: '__egress__' });
+ const cleanup = () => { r.consumers.delete(consumer.id); r.transports.delete(key); };
+ consumer.on('transportclose', cleanup);
+ consumer.on('producerclose', () => {
+ cleanup();
+ try { transport.close(); } catch { /* gone */ }
+ emitter.emit('consumer-closed', { sessionId, peerId: '__egress__', consumerId: consumer.id, kind: consumer.kind });
+ });
+ const codec = consumer.rtpParameters.codecs[0];
+ const encoding = consumer.rtpParameters.encodings && consumer.rtpParameters.encodings[0];
+ if (consumer.kind === 'video') {
+ for (const d of [0, 500, 1500, 3000]) setTimeout(() => { consumer.requestKeyFrame().catch(() => {}); }, d).unref?.();
+ }
+ return {
+ transportId: transport.id,
+ consumerId: consumer.id,
+ kind: consumer.kind,
+ payloadType: codec && codec.payloadType,
+ clockRate: codec && codec.clockRate,
+ mimeType: codec && codec.mimeType,
+ channels: codec && codec.channels,
+ ssrc: encoding && encoding.ssrc,
+ codecParameters: codec && codec.parameters,
+ };
+ }
+
+ function closePlainConsumer(sessionId, transportId) {
+ const r = room(sessionId);
+ if (!r) return;
+ const key = `plain-${transportId}`;
+ const transport = r.transports.get(key);
+ if (!transport) return;
+ for (const [id, entry] of r.consumers) {
+ if (entry.peerId === '__egress__' && entry.consumer && entry.consumer.transportId === transportId) { try { entry.consumer.close(); } catch { /* gone */ } r.consumers.delete(id); }
+ }
+ try { transport.close(); } catch { /* gone */ }
+ r.transports.delete(key);
+ }
+
+ function findProducerByKind(sessionId, kind) {
+ const r = room(sessionId);
+ if (!r) return null;
+ for (const [id, { producer, peerId }] of r.producers) if (producer.kind === kind && !producer.closed) return { id, peerId };
+ return null;
+ }
+
+ function hasProducers(sessionId) {
+ const r = room(sessionId);
+ return r ? [...r.producers.values()].some(({ producer }) => !producer.closed) : false;
+ }
+
+ function getProducers(sessionId) {
+ const r = room(sessionId);
+ if (!r) return [];
+ return [...r.producers.entries()]
+ .filter(([, { producer }]) => !producer.closed)
+ .map(([id, { producer, peerId, transportId }]) => {
+ const transport = r.transports.get(`${peerId}-${transportId}`);
+ return { id, peerId, kind: producer.kind, paused: producer.paused, dtlsState: transport ? transport.dtlsState : 'no-transport', iceState: transport ? transport.iceState : 'no-transport' };
+ });
+ }
+
+ /** Live counts unique consumer peers (viewers) in a room. */
+ function getViewerCount(sessionId) {
+ const r = room(sessionId);
+ if (!r) return 0;
+ const peers = new Set();
+ for (const { peerId } of r.consumers.values()) if (peerId !== '__egress__') peers.add(peerId);
+ return peers.size;
+ }
+
+ function closePeer(sessionId, peerId) {
+ const r = room(sessionId);
+ if (!r) return;
+ for (const [id, entry] of [...r.consumers]) {
+ if (entry.peerId === peerId) { try { entry.consumer.close(); } catch { /* gone */ } r.consumers.delete(id); }
+ }
+ for (const [key, transport] of [...r.transports]) {
+ if (key.startsWith(`${peerId}-`)) { try { transport.close(); } catch { /* gone */ } r.transports.delete(key); }
+ }
+ for (const [id, entry] of [...r.producers]) {
+ if (entry.peerId === peerId) { try { entry.producer.close(); } catch { /* gone */ } r.producers.delete(id); }
+ }
+ }
+
+ function closeSession(sessionId) {
+ const r = routers.get(sessionId);
+ if (!r) return;
+ for (const transport of r.transports.values()) { try { transport.close(); } catch { /* gone */ } }
+ try { r.router.close(); } catch { /* gone */ }
+ routers.delete(sessionId);
+ }
+
+ function closeAll() {
+ for (const sessionId of [...routers.keys()]) closeSession(sessionId);
+ for (const w of workers.splice(0)) { try { w.close(); } catch { /* gone */ } }
+ }
+
+ return {
+ get available() { return Boolean(mediasoup); },
+ on: (...args) => emitter.on(...args),
+ off: (...args) => emitter.off(...args),
+ init, setAnnouncedIp,
+ getOrCreateRouter, hasRouter, getRouterCapabilities,
+ createTransport, connectTransport, produce, consume,
+ createPlainConsumer, closePlainConsumer,
+ findProducerByKind, hasProducers, getProducers, getViewerCount,
+ closePeer, closeSession, closeAll,
+ };
+}
+
+module.exports = { createSfu };
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * WS signaling for WebRTC, modelled on the producer and viewer halves of OpenVibe.Live's
+ * server/streaming/broadcast-server.js so Live's broadcast and watch pages can point their
+ * mediasoup-client at OpenRe:
+ *
+ * ws://<host>:<port>/b/<ingest key> the broadcaster (Live's role: produce into the SFU)
+ * ws://<host>:<port>/w/<session playback id> a viewer (consume; never carries the key)
+ *
+ * Broadcaster messages: sfu-get-capabilities | sfu-create-transport | sfu-connect-transport |
+ * sfu-produce | sfu-stop-produce → the sfu-* replies Live's client expects.
+ * Viewer messages: watch | sfu-viewer-create-transport | sfu-viewer-connect-transport |
+ * sfu-viewer-consume → sfu-viewer-ready/…-consumed.
+ *
+ * Admission of the broadcaster uses the same resolveIngestKey/admit path as WHIP and RTMP (it is the
+ * caller's `admit` callback). A viewer is keyed by the session's playback id and served by the
+ * generation that holds the session; another generation drops the socket (same rule as JSMPEG).
+ * Viewer counts are read from the SFU (active consumer peers) by the worker's heartbeat, not here.
+ */
+const { WebSocketServer } = require('ws');
+
+const BACKPRESSURE = 512 * 1024;
+
+function safeSend(ws, obj, log) {
+ if (!ws || ws.readyState !== ws.OPEN) return;
+ if (ws.bufferedAmount > BACKPRESSURE) { log.warn(`[webrtc] dropping ${obj && obj.type} — ws backpressure ${ws.bufferedAmount}`); return; }
+ try { ws.send(JSON.stringify(obj)); } catch (err) { log.warn(`[webrtc] send ${obj && obj.type} failed: ${err.message}`); }
+}
+
+function createSignaling({ sfu, iceServers, admit, endSession, log = console }) {
+ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
+ /** ws → { role, sessionId, peerId } */
+ const clients = new Map();
+
+ const ice = () => (iceServers || []).map(urls => ({ urls }));
+
+ function handleBroadcaster(ws, key) {
+ ws.on('message', async (data) => {
+ let msg;
+ try { msg = JSON.parse(data); } catch { return; }
+ const client = clients.get(ws);
+ if (!client) return;
+ const { sessionId, peerId } = client;
+ try {
+ switch (msg.type) {
+ case 'sfu-get-capabilities':
+ safeSend(ws, { type: 'sfu-capabilities', rtpCapabilities: await sfu.getRouterCapabilities(sessionId) }, log);
+ break;
+ case 'sfu-create-transport': {
+ const t = await sfu.createTransport(sessionId, peerId);
+ safeSend(ws, { type: 'sfu-transport-created', id: t.id, iceParameters: t.iceParameters, iceCandidates: t.iceCandidates, dtlsParameters: t.dtlsParameters, iceServers: ice() }, log);
+ break;
+ }
+ case 'sfu-connect-transport':
+ await sfu.connectTransport(sessionId, peerId, msg.transportId, msg.dtlsParameters);
+ safeSend(ws, { type: 'sfu-transport-connected', transportId: msg.transportId }, log);
+ break;
+ case 'sfu-produce': {
+ const r = await sfu.produce(sessionId, peerId, msg.transportId, msg.kind, msg.rtpParameters);
+ safeSend(ws, { type: 'sfu-produced', id: r.id, kind: msg.kind }, log);
+ break;
+ }
+ case 'sfu-stop-produce':
+ sfu.closePeer(sessionId, peerId);
+ break;
+ default:
+ break;
+ }
+ } catch (err) {
+ log.warn(`[webrtc] signaling ${msg.type} failed for ${sessionId}: ${err.message}`);
+ safeSend(ws, { type: 'sfu-error', error: err.message }, log);
+ }
+ });
+ }
+
+ function handleViewer(ws, sessionId) {
+ ws.on('message', async (data) => {
+ let msg;
+ try { msg = JSON.parse(data); } catch { return; }
+ const client = clients.get(ws);
+ if (!client) return;
+ const peerId = client.peerId;
+ try {
+ switch (msg.type) {
+ case 'watch': {
+ if (!sfu.hasProducers(sessionId)) { safeSend(ws, { type: 'watch-queued', reason: 'awaiting_source' }, log); break; }
+ safeSend(ws, {
+ type: 'sfu-viewer-ready',
+ rtpCapabilities: await sfu.getRouterCapabilities(sessionId),
+ producers: sfu.getProducers(sessionId).filter(p => !p.paused && p.dtlsState === 'connected' && (p.iceState === 'connected' || p.iceState === 'completed')).map(p => ({ id: p.id, kind: p.kind })),
+ }, log);
+ break;
+ }
+ case 'sfu-viewer-create-transport': {
+ const t = await sfu.createTransport(sessionId, peerId);
+ safeSend(ws, { type: 'sfu-viewer-transport-created', id: t.id, iceParameters: t.iceParameters, iceCandidates: t.iceCandidates, dtlsParameters: t.dtlsParameters, iceServers: ice() }, log);
+ break;
+ }
+ case 'sfu-viewer-connect-transport':
+ await sfu.connectTransport(sessionId, peerId, msg.transportId, msg.dtlsParameters);
+ safeSend(ws, { type: 'sfu-viewer-transport-connected', transportId: msg.transportId }, log);
+ break;
+ case 'sfu-viewer-consume': {
+ const r = await sfu.consume(sessionId, peerId, msg.transportId, msg.producerId, msg.rtpCapabilities);
+ safeSend(ws, { type: 'sfu-viewer-consumed', id: r.id, producerId: r.producerId, kind: r.kind, rtpParameters: r.rtpParameters }, log);
+ break;
+ }
+ default:
+ break;
+ }
+ } catch (err) {
+ log.warn(`[webrtc] viewer signaling ${msg.type} failed for ${sessionId}: ${err.message}`);
+ safeSend(ws, { type: 'sfu-viewer-error', error: err.message }, log);
+ }
+ });
+ }
+
+ /** http server 'upgrade' handler. Returns true when the request was a signaling upgrade. */
+ async function handleUpgrade(req, socket, head) {
+ let pathname;
+ try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; } catch { return false; }
+ const bMatch = /^\/b\/([^/]+)\/?$/.exec(pathname);
+ const wMatch = /^\/w\/(ses_[0-9A-HJKMNP-TV-Z]{26})\/?$/.exec(pathname);
+ if (!bMatch && !wMatch) return false;
+
+ if (bMatch) {
+ // Admission runs before the handshake is completed, so a bad key never gets a socket.
+ let adm;
+ try { adm = await admit(decodeURIComponent(bMatch[1])); } catch (err) { adm = { error: err.message }; }
+ if (!adm || adm.error) {
+ log.log(`[webrtc] broadcast refused (${adm && adm.error}) from ${socket.remoteAddress || '?'}`);
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRe-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');
+ socket.destroy();
+ return true;
+ }
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ const peerId = adm.peerId || `bc-${adm.sessionId}`;
+ clients.set(ws, { role: 'broadcaster', sessionId: adm.sessionId, peerId });
+ ws.on('close', () => { clients.delete(ws); sfu.closePeer(adm.sessionId, peerId); endSession?.(adm.sessionId, 'broadcaster_disconnected'); });
+ ws.on('error', () => {});
+ safeSend(ws, { type: 'welcome', peerId, role: 'broadcaster', sessionId: adm.sessionId, iceServers: ice() }, log);
+ handleBroadcaster(ws, adm);
+ });
+ return true;
+ }
+
+ // Viewer: only the generation that holds the session serves it.
+ if (!sfu.hasRouter(wMatch[1])) { socket.destroy(); return true; }
+ const peerId = `vw-${Math.random().toString(36).slice(2, 10)}`;
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ clients.set(ws, { role: 'viewer', sessionId: wMatch[1], peerId });
+ ws.on('close', () => { clients.delete(ws); sfu.closePeer(wMatch[1], peerId); });
+ ws.on('error', () => {});
+ safeSend(ws, { type: 'welcome', peerId, role: 'viewer', sessionId: wMatch[1], viewerCount: sfu.getViewerCount(wMatch[1]), iceServers: ice() }, log);
+ handleViewer(ws, wMatch[1]);
+ });
+ return true;
+ }
+
+ function closeAll() {
+ for (const ws of clients.keys()) { try { ws.close(); } catch { /* gone */ } }
+ clients.clear();
+ try { wss.close(); } catch { /* gone */ }
+ }
+
+ return { handleUpgrade, closeAll, clients };
+}
+
+module.exports = { createSignaling };