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).

OP OpenVibers committed 29 Sept 2026 · 75a5dd7846 · 33 files, +3216 −45


Changes

ddiff --git a/README.md b/README.md
iindex abc2ea6..c4737c4 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/deploy/nginx/openre.stream.conf b/deploy/nginx/openre.stream.conf
iindex 256c291..d96a0a7 100644
---- a/deploy/nginx/openre.stream.conf
++++ b/deploy/nginx/openre.stream.conf
@@@ -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.)
++## ═══════════════════════════════════════════════════════════════
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
iindex 27bdbc7..33d46ce 100755
---- a/deploy/scripts/deploy-legacy.sh
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -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}'); do
case "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esac
done
}
ddiff --git a/deploy/systemd/[email protected] b/deploy/systemd/[email protected]
nnew file mode 100644
iindex 0000000..5115643
---- /dev/null
++++ b/deploy/systemd/[email protected]
@@@ -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
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex d93c6f4..f273a3e 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -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
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
`/etc/openvibe/host.json`: `workerUnits`, and the drill block with `OPENRE_DRILL`. Then run
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex b57851f..2be2ca3 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -25,6 +25,7 @@
],
"eventsProduced": [
"openre.session.started",
++ "openre.session.updated",
"openre.session.ended",
"openre.session.failed",
"openre.output.healthy",
ddiff --git a/migrations/0003_recording_rtp.sql b/migrations/0003_recording_rtp.sql
nnew file mode 100644
iindex 0000000..61fbec7
---- /dev/null
++++ b/migrations/0003_recording_rtp.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 397daa0..a228977 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex fda7ed4..0b83b82 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 16a67fc..36f3363 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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),
ddiff --git a/server/config.js b/server/config.js
iindex 13ada5f..7079da1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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),
ddiff --git a/server/events.js b/server/events.js
iindex edc1f09..8a1f564 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -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',
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 5ec5713..ce29809 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -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 };
++ },
};
}
ddiff --git a/server/migrate-live.js b/server/migrate-live.js
iindex 9c24edd..8e6eefa 100644
---- a/server/migrate-live.js
++++ b/server/migrate-live.js
@@@ -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).`);
}
ddiff --git a/server/store/definitions.js b/server/store/definitions.js
iindex efdd6f2..2c780b0 100644
---- a/server/store/definitions.js
++++ b/server/store/definitions.js
@@@ -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;
}
ddiff --git a/server/store/index.js b/server/store/index.js
iindex c576176..9ab3244 100644
---- a/server/store/index.js
++++ b/server/store/index.js
@@@ -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() }, fetchImp
db = 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 };
}
ddiff --git a/server/store/recordings.js b/server/store/recordings.js
iindex 1b7e721..e117365 100644
---- a/server/store/recordings.js
++++ b/server/store/recordings.js
@@@ -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, lo
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) {
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);
ddiff --git a/server/store/sessions.js b/server/store/sessions.js
iindex 848b6b1..3d4b28a 100644
---- a/server/store/sessions.js
++++ b/server/store/sessions.js
@@@ -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),
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex a710e09..d5ce4e1 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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');
ddiff --git a/test/jsmpeg-e2e.test.js b/test/jsmpeg-e2e.test.js
iindex ce679b6..4c3c8d3 100644
---- a/test/jsmpeg-e2e.test.js
++++ b/test/jsmpeg-e2e.test.js
@@@ -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 () => {
ddiff --git a/test/migration.test.js b/test/migration.test.js
iindex 96826c7..2f6f3c3 100644
---- a/test/migration.test.js
++++ b/test/migration.test.js
@@@ -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, /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`);
});
ddiff --git a/test/webrtc-e2e.test.js b/test/webrtc-e2e.test.js
nnew file mode 100644
iindex 0000000..17472fb
---- /dev/null
++++ b/test/webrtc-e2e.test.js
@@@ -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();
ddiff --git a/test/webrtc.test.js b/test/webrtc.test.js
nnew file mode 100644
iindex 0000000..40c8be7
---- /dev/null
++++ b/test/webrtc.test.js
@@@ -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();
ddiff --git a/workers/jsmpeg.js b/workers/jsmpeg.js
iindex 3742369..a92d38c 100644
---- a/workers/jsmpeg.js
++++ b/workers/jsmpeg.js
@@@ -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 */ } }
ddiff --git a/workers/restream-worker.js b/workers/restream-worker.js
iindex 97c1f28..61c3fbf 100644
---- a/workers/restream-worker.js
++++ b/workers/restream-worker.js
@@@ -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 }));
}
ddiff --git a/workers/restream/ffmpeg-args.js b/workers/restream/ffmpeg-args.js
iindex dd7a9dc..66fd441 100644
---- a/workers/restream/ffmpeg-args.js
++++ b/workers/restream/ffmpeg-args.js
@@@ -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,
};
ddiff --git a/workers/restream/output-runner.js b/workers/restream/output-runner.js
iindex 923b434..d53d493 100644
---- a/workers/restream/output-runner.js
++++ b/workers/restream/output-runner.js
@@@ -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) {
ddiff --git a/workers/rtmp-ingest.js b/workers/rtmp-ingest.js
iindex 84fad84..d688004 100644
---- a/workers/rtmp-ingest.js
++++ b/workers/rtmp-ingest.js
@@@ -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 paths
const 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; }
ddiff --git a/workers/thumbnails.js b/workers/thumbnails.js
nnew file mode 100644
iindex 0000000..d1f2c90
---- /dev/null
++++ b/workers/thumbnails.js
@@@ -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 };
ddiff --git a/workers/webrtc.js b/workers/webrtc.js
iindex 0e7a234..636fdfd 100644
---- a/workers/webrtc.js
++++ b/workers/webrtc.js
@@@ -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); });
++}
ddiff --git a/workers/webrtc/sdp.js b/workers/webrtc/sdp.js
nnew file mode 100644
iindex 0000000..463ff38
---- /dev/null
++++ b/workers/webrtc/sdp.js
@@@ -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,
++};
ddiff --git a/workers/webrtc/sfu.js b/workers/webrtc/sfu.js
nnew file mode 100644
iindex 0000000..79a648c
---- /dev/null
++++ b/workers/webrtc/sfu.js
@@@ -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 };
ddiff --git a/workers/webrtc/signaling.js b/workers/webrtc/signaling.js
nnew file mode 100644
iindex 0000000..4156119
---- /dev/null
++++ b/workers/webrtc/signaling.js
@@@ -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 };