openvibers/openvibe.live Commits
WHIP: open CORS so a backend-less web page can publish; docs/whip.md + browser publisher
Changes
@@@ -65,6 +65,7 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)++- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)@@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).--- WHIP/HTTP ingestion support (`whip.openvibe.live`).++- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).- real-time broadcast and control channels.### Media (VODs / clips / pastes / thumbnails)@@@ -2,7 +2,8 @@## Getting Started- [Onboarding Guide](onboarding.md) — New user walkthrough--- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, and JSMPEG++- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, WHIP, and JSMPEG++- [WHIP Ingest API](whip.md) — WHIP endpoint reference, CORS, error codes · [Publishing from a browser](whip.md#publishing-from-a-browser) (static site, no backend)## Features- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS@@@ -1,6 +1,6 @@# Broadcasting Guide--OpenVibe.Live supports three live broadcast protocols, each suited to different use cases.++OpenVibe.Live supports four live broadcast methods, each suited to different use cases.## Broadcast Methods@@@ -16,6 +16,12 @@ OpenVibe.Live supports three live broadcast protocols, each suited to different- **Setup**: Copy Server URL and Stream Key into OBS- **Features**: Full OBS feature set, live thumbnail preview on broadcast page++### WHIP (OBS, FFmpeg, GStreamer — or any web page)++- **Best for**: WebRTC-native encoders and custom publishers, including browser-only sites++- **Latency**: Sub-second++- **Setup**: Choose *Streaming method: WHIP*, copy the WHIP URL into OBS (Service: WHIP) or `ffmpeg -f whip`++- **Features**: VP8/H.264 + Opus, auto-creates the live session on first POST, open CORS so a static page can publish with `RTCPeerConnection` + `fetch()` — see the [WHIP Ingest API](whip.md) / [Publishing from a browser](whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html)++### JSMPEG (FFmpeg)- **Best for**: Headless/embedded streaming (Raspberry Pi, IP cameras, 3D printers)- **Latency**: ~1 secondnnew file mode 100644@@@ -0,0 +1,167 @@++# WHIP Ingest API++++OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol, [RFC 9725](https://www.rfc-editor.org/rfc/rfc9725)). Anything that can produce a WebRTC offer can go live with a single HTTP request — OBS 30+, FFmpeg 7+, GStreamer, hardware encoders, **and a plain web page**.++++> **Can a frontend-only site stream to WHIP?** Yes. The endpoint is open to every origin (CORS `*`) and authenticates with your stream key, so a static page with no backend can publish straight from the browser using only `RTCPeerConnection` + `fetch()`. Jump to [Publishing from a browser](#publishing-from-a-browser), or try the hosted single-file demo at **<https://openvibe.live/whip-publisher.html>** (view-source to copy it).++++## Endpoint++++```++POST https://whip.openvibe.live/whip/{streamKey}++Content-Type: application/sdp++```++++| URL form | Credential | Use it when |++|---|---|---|++| `/whip/{streamKey}` | the key is in the path (optionally also as `Authorization: Bearer {streamKey}` — OBS sends both) | **Default.** Browsers, FFmpeg, GStreamer, OBS. |++| `/whip/{slotId}` | `Authorization: Bearer {streamKey}` or `?key={streamKey}` | Tools that want a stable, non-secret URL with the secret in a header. |++| `/whip/{streamSessionId}` | `Authorization: Bearer {openvibe.network JWT}` | Internal / legacy. Requires an already-live session; `hbt_` API tokens are **not** accepted here. |++++`{streamKey}` is a 32-char hex string that belongs to one **stream slot** (a "managed stream"). Posting to it auto-creates a live session on that slot if none exists — you do not need to click *Go Live* first. The slot's streaming method must be **Browser** or **WHIP** (protocol `webrtc`); RTMP/JSMPEG slots answer `409 wrong_protocol`.++++### Getting your WHIP URL++++- **UI:** *Broadcast → your stream → Streaming method: WHIP* → copy the WHIP URL. (Same URL the OBS instructions show.)++- **API:** `GET /api/streams/managed/{slotId}/profile` (JWT or `hbt_` token) returns `stream_key` and `whip_url_base`; the URL is `${whip_url_base}/whip/${stream_key}`.++++Treat the URL as a password: anyone holding it can broadcast to your channel. Regenerate the key from the dashboard if it leaks.++++## Session lifecycle++++1. **`POST /whip/{streamKey}`** with the SDP offer as the body.++ `201 Created` · body is the SDP answer (`Content-Type: application/sdp`) · `Location` header is the **session resource URL** — keep it.++2. Apply the answer, and ICE/DTLS connect. The stream is shown as live once media flows (the *Live* state in your app corresponds to `RTCPeerConnection.connectionState === 'connected'`).++3. **`PATCH {Location}`** (`Content-Type: application/trickle-ice-sdpfrag`) — accepted and acknowledged with `204`, but **not required**: the server is an ICE-lite agent and includes all of its candidates in the answer, so a client never needs to trickle. Browsers can post the offer immediately after `setLocalDescription()` without waiting for gathering.++4. **`DELETE {Location}`** — ends the session. When the last session on a stream is gone, the stream ends (VOD finalises, restreams stop, viewers are notified). Sessions also end on their own if ICE fails, or 15 s after ICE reports `disconnected` without recovering.++++Posting a second offer to the same slot replaces the previous session (useful for reconnecting after a network change).++++## Media requirements++++| | Accepted | Notes |++|---|---|---|++| Video | **VP8**, **H.264** (Constrained Baseline, `profile-level-id=42e01f`, `packetization-mode=1`) | The server takes the **first** codec in your offer that it supports. AV1 / VP9 / HEVC are skipped; if nothing else is offered you get `406 no_compatible_codecs`. |++| Audio | **Opus** 48 kHz (mono or stereo) | |++| Encodings | one per m-section (SSRC or a single RID) | No simulcast / SVC — offer a single encoding. RTX (`a=ssrc-group:FID`) is supported. |++| Transport | BUNDLE, `rtcp-mux`, DTLS-SRTP | Offer `a=setup:actpass`; the answer is `a=setup:passive` + `a=ice-lite`. A data-channel m-section is rejected (`port 0`) but does not fail the offer. |++++No STUN/TURN is needed: the server sits on a public address and the client connects straight to the candidates in the answer. Outbound UDP must be allowed.++++## Errors++++Errors are JSON (`{ "error": "...", "error_code": "..." }`) and also carry the code in an `X-WHIP-ERROR` header, which is exposed to cross-origin callers.++++| Status | `X-WHIP-ERROR` | Meaning |++|---|---|---|++| 400 | `missing_sdp` / `invalid_sdp` / `invalid_rtp_encoding` / `invalid_stream_id` | Body is not a usable SDP offer (e.g. a video section with no SSRC or RID). |++| 401 | `invalid_stream_key` / `bearer_mismatch` / `invalid_key` / `invalid_token` / `authentication_required` / `user_not_found` | Key or token not recognised, or path key ≠ bearer key. |++| 403 | `user_banned` / `not_your_stream` | |++| 404 | `slot_not_found` / `stream_not_found` / `session_not_found` | The last one is a `PATCH`/`DELETE` to a resource that already ended. |++| 406 | `no_compatible_codecs` | Offer only VP8 / H.264 / Opus. |++| 409 | `wrong_protocol` / `stream_not_live` | Slot is set to RTMP/JSMPEG (change the streaming method), or (JWT form) session is not live. |++| 502 | `transport_creation_failed` / `dtls_negotiation_failed` / `producer_creation_failed` | SFU could not set up the session — retry. |++| 503 | `sfu_unavailable` / `whip_unavailable` | Server-side WebRTC is down. |++++## Cross-origin (CORS)++++Every `/whip/*` response, including errors, carries:++++```++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++```++++There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:++++```bash++curl -si -X OPTIONS https://whip.openvibe.live/whip/x \++ -H 'Origin: https://my-static-site.example' \++ -H 'Access-Control-Request-Method: POST' \++ -H 'Access-Control-Request-Headers: content-type'++# → HTTP/2 204 with the headers above++```++++## Publishing from a browser++++> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>++> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.++++**Spec in five lines (paste-ready):**++++```text++OpenVibe.Live WHIP ingest — browser publisher requirements++1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).++2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).++3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.++4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.++5. Stop with DELETE <Location> (use fetch keepalive on pagehide). Errors: JSON + X-WHIP-ERROR header (see table above).++```++++A complete publisher is about forty lines. This is the core of [`whip-publisher.html`](../public/whip-publisher.html) (hosted at <https://openvibe.live/whip-publisher.html>), which adds a UI, screen-share, bitrate stats and clean-up on tab close:++++```html++<video id="preview" autoplay muted playsinline></video>++<button id="go">Go live</button>++<script>++const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';++let pc, resourceUrl;++++async function goLive() {++ const stream = await navigator.mediaDevices.getUserMedia({++ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },++ audio: true,++ });++ document.getElementById('preview').srcObject = stream;++++ pc = new RTCPeerConnection({ iceServers: [] }); // ICE-lite server: no STUN needed++ for (const track of stream.getTracks()) {++ pc.addTransceiver(track, {++ direction: 'sendonly',++ sendEncodings: track.kind === 'video' ? [{ maxBitrate: 2_500_000 }] : undefined,++ });++ }++ pc.onconnectionstatechange = () => console.log('connection:', pc.connectionState);++++ await pc.setLocalDescription(await pc.createOffer()); // no need to wait for ICE gathering++++ const res = await fetch(WHIP_URL, {++ method: 'POST',++ headers: { 'Content-Type': 'application/sdp' },++ body: pc.localDescription.sdp,++ });++ if (res.status !== 201) {++ throw new Error(`WHIP ${res.status} ${res.headers.get('X-WHIP-ERROR') || ''}`);++ }++ resourceUrl = new URL(res.headers.get('Location'), WHIP_URL).href;++ await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });++}++++async function stop() {++ if (resourceUrl) await fetch(resourceUrl, { method: 'DELETE', keepalive: true });++ pc?.close();++}++++document.getElementById('go').onclick = () => goLive().catch(console.error);++addEventListener('pagehide', () => resourceUrl && fetch(resourceUrl, { method: 'DELETE', keepalive: true }));++</script>++```++++Things worth knowing:++++- **Secure context.** `getUserMedia` only works on `https://`, `localhost`, or a local `file://` — plain `http://` hosting will not get camera access.++- **Screen share.** Swap `getUserMedia` for `navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })`. Listen for the video track's `ended` event — that is the browser's own *Stop sharing* button.++- **Codec choice.** Chrome and Firefox offer VP8 first, Safari offers H.264 first; both are accepted as-is. If you use `RTCRtpTransceiver.setCodecPreferences()`, keep VP8 or H.264 at the front — the server negotiates the first supported codec it sees.++- **Bitrate.** Set `maxBitrate` in `sendEncodings` (or later via `sender.setParameters()`); 2.5 Mbps for 720p30 and 4.5 Mbps for 1080p30 are good defaults.++- **Reconnecting.** On `connectionState === 'failed'`, build a fresh `RTCPeerConnection` and `POST` again — the new session replaces the old one on the same slot, and viewers pick up automatically.++- **Ending cleanly.** Always `DELETE` the resource (use `keepalive: true` so it survives tab close). If you don't, the stream lingers until the 15-second ICE grace period expires.++- **The key is visible in the page.** That is inherent to a backend-less publisher and the same trust model as OBS; keep the page private (or have the streamer paste their own key at runtime, as the hosted demo does) rather than committing a key into a public repo.++++## Other clients++++- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.++- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`++- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).++++The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.nnew file mode 100644@@@ -0,0 +1,378 @@++<!DOCTYPE html>++<html lang="en">++<head>++ <meta charset="UTF-8">++ <meta name="viewport" content="width=device-width, initial-scale=1.0">++ <title>Browser WHIP Publisher — OpenVibe.Live</title>++ <!--++ OpenVibe.Live — browser-only WHIP publisher.++++ This page has NO backend and NO dependencies. It is one self-contained HTML file++ that publishes your camera + mic (or your screen) to an OpenVibe.Live WHIP endpoint++ using nothing but RTCPeerConnection and fetch(). Save it, host it anywhere++ (GitHub Pages, Cloudflare Pages, Netlify, a local file://), or lift the script++ into your own site.++++ Protocol walkthrough + API reference:++ https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md++++ Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })++ window.OpenVibeWhip.stop()++ window.OpenVibeWhip.state → { phase, connectionState, resourceUrl, error }++ -->++ <style>++ :root {++ --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --bg-input: #12121a;++ --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;++ --accent: #8b5cf6; --accent-light: #a78bfa; --accent-dark: #6d28d9;++ --ok: #2ecc71; --warn: #f1c40f; --bad: #e74c3c; --border: #2a2a32;++ }++ * { box-sizing: border-box; }++ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++ a { color: var(--accent-light); }++ main { max-width: 900px; margin: 0 auto; padding: 24px 16px 48px; }++ h1 { font-size: 1.35rem; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }++ h1 .logo { width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); display: inline-block; }++ .sub { color: var(--text-2); margin: 0 0 20px; font-size: 0.92rem; }++ .card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }++ label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.04em; }++ input, select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text); font: inherit; }++ input:focus, select:focus { outline: none; border-color: var(--accent); }++ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }++ @media (max-width: 600px) { .row { grid-template-columns: 1fr; } }++ .actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; flex-wrap: wrap; }++ button { padding: 10px 18px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-weight: 600; cursor: pointer; }++ button:disabled { opacity: 0.45; cursor: not-allowed; }++ .btn-go { background: var(--accent); color: #fff; }++ .btn-go:not(:disabled):hover { background: var(--accent-dark); }++ .btn-stop { background: transparent; color: var(--bad); border-color: var(--bad); }++ .btn-stop:not(:disabled):hover { background: rgba(231, 76, 60, 0.12); }++ .pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: var(--bg-3); font-size: 0.85rem; color: var(--text-2); }++ .pill::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }++ .pill[data-phase="connecting"]::before, .pill[data-phase="starting"]::before { background: var(--warn); }++ .pill[data-phase="live"]::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(46, 204, 113, 0.18); }++ .pill[data-phase="error"]::before { background: var(--bad); }++ video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; display: block; }++ .stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 10px; font-size: 0.85rem; color: var(--text-2); font-variant-numeric: tabular-nums; }++ .stats b { color: var(--text); font-weight: 600; }++ pre.log { margin: 0; max-height: 220px; overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }++ pre.log .err { color: var(--bad); } pre.log .ok { color: var(--ok); }++ .hint { font-size: 0.82rem; color: var(--muted); margin-top: 6px; }++ .hint code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; color: var(--text-2); }++ </style>++</head>++<body>++<main>++ <h1><span class="logo"></span> Browser WHIP Publisher</h1>++ <p class="sub">Go live on OpenVibe.Live from any web page — no OBS, no backend, no libraries. Just this file.</p>++++ <div class="card">++ <label for="whip-url">WHIP URL</label>++ <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">++ <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>++++ <label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>++ <input id="whip-token" type="password" placeholder="stream key" autocomplete="off">++++ <div class="row">++ <div>++ <label for="whip-source">Source</label>++ <select id="whip-source">++ <option value="camera">Camera + microphone</option>++ <option value="screen">Screen / window (+ system audio if offered)</option>++ </select>++ </div>++ <div>++ <label for="whip-quality">Quality</label>++ <select id="whip-quality">++ <option value="1280x720@2500">720p · 2.5 Mbps</option>++ <option value="1920x1080@4500">1080p · 4.5 Mbps</option>++ <option value="854x480@1200">480p · 1.2 Mbps</option>++ </select>++ </div>++ </div>++++ <div class="actions">++ <button id="whip-start" class="btn-go">Go live</button>++ <button id="whip-stop" class="btn-stop" disabled>Stop</button>++ <span id="whip-status" class="pill" data-phase="idle">Idle</span>++ </div>++ </div>++++ <div class="card">++ <video id="whip-preview" autoplay muted playsinline></video>++ <div class="stats">++ <span>Bitrate <b id="st-bitrate">–</b></span>++ <span>FPS <b id="st-fps">–</b></span>++ <span>Resolution <b id="st-res">–</b></span>++ <span>Codec <b id="st-codec">–</b></span>++ <span>Uptime <b id="st-uptime">–</b></span>++ </div>++ </div>++++ <div class="card">++ <pre id="whip-log" class="log">Ready.</pre>++ </div>++++ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>++</main>++++<script>++(() => {++ 'use strict';++++ const $ = (id) => document.getElementById(id);++ const els = {++ url: $('whip-url'), token: $('whip-token'), source: $('whip-source'), quality: $('whip-quality'),++ start: $('whip-start'), stop: $('whip-stop'), status: $('whip-status'), preview: $('whip-preview'), log: $('whip-log'),++ bitrate: $('st-bitrate'), fps: $('st-fps'), res: $('st-res'), codec: $('st-codec'), uptime: $('st-uptime'),++ };++ const STORAGE_KEY = 'openvibe-whip-url';++++ const state = {++ phase: 'idle', // idle | starting | connecting | live | error++ connectionState: null,++ resourceUrl: null, // WHIP resource (Location header) — DELETE it to end the session++ error: null,++ pc: null,++ stream: null,++ startedAt: null,++ statsTimer: null,++ lastStats: null,++ };++++ // ── UI helpers ─────────────────────────────────────────────++ function log(message, cls) {++ const line = document.createElement('div');++ if (cls) line.className = cls;++ line.textContent = `${new Date().toLocaleTimeString()} ${message}`;++ els.log.appendChild(line);++ els.log.scrollTop = els.log.scrollHeight;++ }++++ function setPhase(phase, label) {++ state.phase = phase;++ els.status.dataset.phase = phase;++ els.status.textContent = label || phase[0].toUpperCase() + phase.slice(1);++ const busy = phase === 'starting' || phase === 'connecting' || phase === 'live';++ els.start.disabled = busy;++ els.stop.disabled = !busy;++ for (const el of [els.url, els.token, els.source, els.quality]) el.disabled = busy;++ }++++ function resetStats() {++ for (const el of [els.bitrate, els.fps, els.res, els.codec, els.uptime]) el.textContent = '–';++ }++++ // ── Media ──────────────────────────────────────────────────++ async function getSource(kind, width, height) {++ if (kind === 'screen') {++ return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });++ }++ return navigator.mediaDevices.getUserMedia({++ video: { width: { ideal: width }, height: { ideal: height }, frameRate: { ideal: 30 } },++ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },++ });++ }++++ // The server negotiates the FIRST offered codec it supports (VP8, H.264 or Opus), so++ // put the ones it can take at the front. Browsers already default to VP8/H.264 first,++ // this just makes it explicit and future-proof against AV1/VP9-first defaults.++ function preferCodecs(transceiver, kind) {++ try {++ if (!transceiver.setCodecPreferences || !RTCRtpSender.getCapabilities) return;++ const caps = RTCRtpSender.getCapabilities(kind);++ if (!caps) return;++ const wanted = kind === 'video' ? ['video/vp8', 'video/h264'] : ['audio/opus'];++ const rank = (c) => { const i = wanted.indexOf(c.mimeType.toLowerCase()); return i === -1 ? wanted.length : i; };++ transceiver.setCodecPreferences([...caps.codecs].sort((a, b) => rank(a) - rank(b)));++ } catch { /* optional optimisation */ }++ }++++ // ── WHIP ───────────────────────────────────────────────────++ async function start(opts = {}) {++ if (state.phase !== 'idle' && state.phase !== 'error') throw new Error('Already publishing');++++ const url = String(opts.url ?? els.url.value).trim();++ const token = String(opts.token ?? els.token.value).trim();++ const source = opts.source || els.source.value;++ const [dims, kbps] = (opts.quality || els.quality.value).split('@');++ const [width, height] = dims.split('x').map(Number);++ const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;++++ if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {++ throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');++ }++ try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }++++ state.error = null;++ state.resourceUrl = null;++ resetStats();++ setPhase('starting', 'Requesting media…');++++ try {++ const stream = await getSource(source, width, height);++ state.stream = stream;++ els.preview.srcObject = stream;++ for (const track of stream.getTracks()) {++ log(`${track.kind} track: ${track.label || 'unnamed'}`);++ // "Stop sharing" in the browser's screen-share bar ends the track → end the session.++ track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });++ }++++ // OpenVibe's SFU is an ICE-lite server on a public address, so no STUN/TURN is++ // needed: the browser connects straight to the candidates in the SDP answer.++ const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] });++ state.pc = pc;++++ for (const track of stream.getTracks()) {++ const init = { direction: 'sendonly', streams: [stream] };++ if (track.kind === 'video') init.sendEncodings = [{ maxBitrate }]; // one encoding — no simulcast++ const transceiver = pc.addTransceiver(track, init);++ preferCodecs(transceiver, track.kind);++ }++++ pc.addEventListener('connectionstatechange', () => {++ state.connectionState = pc.connectionState;++ log(`connection: ${pc.connectionState}`);++ if (pc.connectionState === 'connected') {++ if (!state.startedAt) state.startedAt = Date.now();++ setPhase('live', 'Live');++ log('Media is flowing — you are live.', 'ok');++ } else if (pc.connectionState === 'failed') {++ stop('connection failed');++ fail(new Error('WebRTC connection failed (ICE/DTLS). Check that UDP is not blocked by a firewall.'));++ } else if (pc.connectionState === 'disconnected' && state.phase === 'live') {++ setPhase('connecting', 'Reconnecting…');++ }++ });++++ setPhase('connecting', 'Negotiating…');++ const offer = await pc.createOffer();++ await pc.setLocalDescription(offer);++++ const headers = { 'Content-Type': 'application/sdp' };++ if (token) headers.Authorization = `Bearer ${token}`;++ log(`POST ${url}`);++ const resp = await fetch(url, { method: 'POST', headers, body: pc.localDescription.sdp });++++ if (resp.status !== 201) {++ const code = resp.headers.get('X-WHIP-ERROR');++ let detail = '';++ try { detail = (await resp.json()).error || ''; } catch { /* non-JSON */ }++ throw new Error(`WHIP endpoint answered HTTP ${resp.status}${code ? ` (${code})` : ''}${detail ? `: ${detail}` : ''}`);++ }++++ // Location is the session resource. It may be absolute or relative — resolve it++ // against the endpoint URL so DELETE always hits the right host.++ const location = resp.headers.get('Location');++ state.resourceUrl = location ? new URL(location, url).toString() : null;++ log(`session created${state.resourceUrl ? `: ${state.resourceUrl}` : ''}`);++++ const answer = await resp.text();++ await pc.setRemoteDescription({ type: 'answer', sdp: answer });++ log('answer applied, waiting for ICE/DTLS…');++ startStats();++ } catch (err) {++ await stop('start failed', { silent: true });++ fail(err);++ throw err;++ }++ }++++ async function stop(reason = 'user', { silent = false } = {}) {++ const { pc, stream, resourceUrl } = state;++ stopStats();++ state.pc = null; state.stream = null; state.resourceUrl = null; state.startedAt = null;++++ if (resourceUrl) {++ // keepalive lets the DELETE complete even if the tab is closing.++ try { await fetch(resourceUrl, { method: 'DELETE', keepalive: true }); log('session deleted'); }++ catch (err) { log(`DELETE failed: ${err.message}`, 'err'); }++ }++ if (pc) { try { pc.close(); } catch { /* */ } }++ if (stream) for (const t of stream.getTracks()) t.stop();++ els.preview.srcObject = null;++ state.connectionState = null;++++ if (!silent) {++ setPhase('idle', 'Idle');++ log(`stopped (${reason})`);++ }++ }++++ function fail(err) {++ state.error = err?.message || String(err);++ setPhase('error', 'Error');++ log(state.error, 'err');++ if (/Permission|NotAllowed/i.test(state.error)) log('Camera/mic access was denied — check the browser permission prompt.', 'err');++ if (!window.isSecureContext) log('getUserMedia needs a secure context: serve this page over https:// (or open it as a local file / localhost).', 'err');++ }++++ // ── Stats ──────────────────────────────────────────────────++ function startStats() {++ stopStats();++ state.statsTimer = setInterval(async () => {++ const pc = state.pc;++ if (!pc) return;++ let report;++ try { report = await pc.getStats(); } catch { return; }++ const now = Date.now();++ let video = null, codecName = null;++ const codecs = new Map();++ for (const s of report.values()) {++ if (s.type === 'codec') codecs.set(s.id, s.mimeType);++ if (s.type === 'outbound-rtp' && s.kind === 'video') video = s;++ }++ if (video) {++ if (state.lastStats && video.bytesSent != null) {++ const kbps = ((video.bytesSent - state.lastStats.bytesSent) * 8) / ((now - state.lastStats.at) / 1000) / 1000;++ els.bitrate.textContent = `${Math.round(kbps)} kbps`;++ }++ state.lastStats = { bytesSent: video.bytesSent, at: now };++ if (video.framesPerSecond != null) els.fps.textContent = String(Math.round(video.framesPerSecond));++ if (video.frameWidth) els.res.textContent = `${video.frameWidth}×${video.frameHeight}`;++ codecName = codecs.get(video.codecId);++ if (codecName) els.codec.textContent = codecName.replace('video/', '');++ }++ if (state.startedAt) {++ const s = Math.floor((now - state.startedAt) / 1000);++ els.uptime.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;++ }++ }, 2000);++ }++ function stopStats() {++ if (state.statsTimer) clearInterval(state.statsTimer);++ state.statsTimer = null;++ state.lastStats = null;++ }++++ // ── Wiring ─────────────────────────────────────────────────++ els.start.addEventListener('click', () => start().catch(() => { /* already surfaced via fail() */ }));++ els.stop.addEventListener('click', () => stop('user'));++ window.addEventListener('pagehide', () => {++ if (state.resourceUrl) fetch(state.resourceUrl, { method: 'DELETE', keepalive: true }).catch(() => {});++ });++++ // Prefill: ?url=… wins, then the last URL used in this browser.++ const params = new URLSearchParams(location.search);++ let saved = null;++ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* */ }++ els.url.value = params.get('url') || saved || '';++ if (params.get('token')) els.token.value = params.get('token');++++ if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {++ fail(new Error('This browser does not support WebRTC publishing.'));++ }++++ window.OpenVibeWhip = {++ start,++ stop,++ get state() {++ return { phase: state.phase, connectionState: state.connectionState, resourceUrl: state.resourceUrl, error: state.error };++ },++ };++})();++</script>++</body>++</html>@@@ -212,6 +212,11 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed++// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the++// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different++// origin from the page itself and would otherwise be blocked by connect-src 'self'.++const whipConnectOrigin = (config.whip?.enabled && normalizeOrigin(config.whip?.publicUrl)) || null;++app.use(helmet({contentSecurityPolicy: {directives: {@@@ -224,7 +229,7 @@ app.use(helmet({// every queued item renders as a broken image, which is what the media// request tab was doing for every YouTube link.imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],-- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],++ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", ...(whipConnectOrigin ? [whipConnectOrigin] : [])],// VODs/clips play from openvibe.media (the /api proxies 302 there), which may// itself redirect to presigned B2/R2 object-store URLs — all must be allowed// or the browser blocks the media element.@@@ -260,7 +265,12 @@ function isAllowedOrigin(origin) {} catch { return false; }}--app.use(cors({++// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every origin —++// that is what lets a static, backend-less site publish from the browser (docs/whip.md).++// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.++app.use('/whip', whipHandler.whipCors);++++const allowlistedCors = cors({origin(origin, callback) {if (!origin) return callback(null, true);if (isAllowedOrigin(origin)) return callback(null, true);@@@ -268,7 +278,11 @@ app.use(cors({return callback(new Error('Origin not allowed by CORS'));},credentials: true,--}));++});++app.use((req, res, next) => {++ if (req.path === '/whip' || req.path.startsWith('/whip/')) return next();++ return allowlistedCors(req, res, next);++});app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));app.use(express.urlencoded({ extended: true, limit: '256kb' }));app.use(cookieParser());@@@ -54,10 +54,53 @@ function buildWhipResourceUrl(req, streamId, resourceId) {}}++// ── CORS ─────────────────────────────────────────────────────++// WHIP is authenticated by the stream key (path / Bearer / ?key=) and never by a++// cookie or browser session, so the endpoint can safely be opened to *every* origin.++// That is what lets a purely static site (GitHub Pages, a local index.html, a++// Cloudflare Pages app…) publish straight from `RTCPeerConnection` + `fetch()` with++// no backend of its own — see docs/whip.md. The global CORS middleware in++// server/index.js is credentialed and allowlisted (it protects the cookie-authenticated++// /api routes), so /whip/* is exempted from it and handled here instead.++//++// `Location` must be exposed or a browser cannot read the resource URL it needs for++// PATCH/DELETE; `X-WHIP-ERROR` is exposed so a client can show the machine-readable++// error code instead of a bare status.++const WHIP_CORS_HEADERS = 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',++ 'Access-Control-Max-Age': '86400',++ // Helmet defaults CORP to same-origin; a CORS-mode fetch is not subject to it,++ // but be explicit so nothing upstream can turn the response opaque.++ 'Cross-Origin-Resource-Policy': 'cross-origin',++});++++function applyWhipCorsHeaders(res) {++ for (const [name, value] of Object.entries(WHIP_CORS_HEADERS)) {++ res.set(name, value);++ }++ return res;++}++++/**++ * Express middleware for everything under /whip. Stamps the open CORS headers on the++ * response up-front (so error responses from the handlers carry them too) and answers++ * preflights directly.++ */++function whipCors(req, res, next) {++ applyWhipCorsHeaders(res);++ if (req.method === 'OPTIONS') {++ return res.status(204).end();++ }++ next();++}++function buildWhipResponseHeaders(req, streamId, resourceId) {return {Location: buildWhipResourceUrl(req, streamId, resourceId),-- 'Access-Control-Expose-Headers': 'Location',++ 'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],};}@@@ -403,7 +446,11 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {const { iceParameters, iceCandidates, dtlsParameters } = transport;const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';-- const mids = [];++ // Only *accepted* m-sections go in the BUNDLE group. RFC 8843 §7.3.3 forbids listing a++ // rejected (port 0) section there, and browsers enforce it — Chrome/Firefox refuse the++ // whole answer, which is fatal for a browser publisher whose extra m-section (e.g. a++ // data channel, or a codec the router lacks) we cannot accept. OBS never checked.++ const bundledMids = [];const serverAddress = iceCandidates?.[0]?.ip || config.mediasoup.announcedIp || '127.0.0.1';const sdpObj = {@@@ -417,9 +464,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {media: [],};-- for (const offerMedia of offerSdp.media || []) {-- const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mids.length);-- mids.push(mid);++ 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) {@@@ -427,12 +473,16 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {type: offerMedia.type,port: 0,protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',-- payloads: '0',++ // Echo the offered format list: a rejected section must still be++ // syntactically complete, and for m=application the "payload" is the++ // literal `webrtc-datachannel` token, not a number.++ payloads: String(offerMedia.payloads || '0'),mid,direction: 'inactive',});continue;}++ bundledMids.push(mid);const answerMedia = {type: offerMedia.type,@@@ -498,8 +548,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {sdpObj.media.push(answerMedia);}-- if (mids.length > 0) {-- sdpObj.groups.push({ type: 'BUNDLE', mids: mids.join(' ') });++ if (bundledMids.length > 0) {++ sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });}return sdpTransform.write(sdpObj);@@@ -1030,11 +1080,7 @@ function handleWhipDelete(req, res) {* OPTIONS /whip/* — CORS/discovery*/function handleWhipOptions(req, res) {-- res.status(204)-- .set('Access-Control-Allow-Methods', 'POST, PATCH, DELETE, OPTIONS')-- .set('Access-Control-Allow-Headers', 'Content-Type, Authorization')-- .set('Access-Control-Expose-Headers', 'Location')-- .end();++ applyWhipCorsHeaders(res.status(204)).end();}/**@@@ -1101,6 +1147,9 @@ module.exports = {handleWhipPatch,handleWhipDelete,handleWhipOptions,++ whipCors,++ applyWhipCorsHeaders,++ WHIP_CORS_HEADERS,buildWhipResourceUrl,buildWhipResponseHeaders,hasSfuProducers,@@@ -41,7 +41,7 @@ assert.notDeepStrictEqual(rtpParameters.encodings[0], {}, 'Encoding object mustconsole.log('✅ WHIP handler RTP encoding regression test passed');--const { buildWhipResponseHeaders, handleWhipOptions } = require('../server/streaming/whip-handler');++const { buildWhipResponseHeaders, handleWhipOptions, whipCors, WHIP_CORS_HEADERS } = require('../server/streaming/whip-handler');const req = {protocol: 'https',@@@ -50,26 +50,66 @@ const req = {const headers = buildWhipResponseHeaders(req, '123', 'resource-abc');assert.strictEqual(headers.Location, 'http://localhost:3000/whip/123/resource-abc');--assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location');++// A browser can only read the resource URL (and our error code) if both are exposed.++assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');assert.ok(!Object.prototype.hasOwnProperty.call(headers, 'Link'));--const res = {-- statusCode: null,-- headers: {},-- ended: false,-- status(code) { this.statusCode = code; return this; },-- set(key, value) { this.headers[key] = value; return this; },-- end() { this.ended = true; },--};++function makeRes() {++ return {++ statusCode: null,++ headers: {},++ ended: false,++ status(code) { this.statusCode = code; return this; },++ set(key, value) { this.headers[key] = value; return this; },++ end() { this.ended = true; },++ };++}++const res = makeRes();handleWhipOptions({}, res);assert.strictEqual(res.statusCode, 204);--assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location');++assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');assert.ok(!('Link' in res.headers));assert.strictEqual(res.ended, true);console.log('✅ WHIP handler response header regression test passed');++// ── Browser (cross-origin) publishing: /whip must be open to every origin ──++// A static site with no backend can only publish if the preflight and the POST both++// come back with Access-Control-Allow-Origin: * — the stream key is the credential.++assert.strictEqual(WHIP_CORS_HEADERS['Access-Control-Allow-Origin'], '*');++for (const method of ['POST', 'PATCH', 'DELETE']) {++ assert.ok(WHIP_CORS_HEADERS['Access-Control-Allow-Methods'].includes(method), `CORS must allow ${method}`);++}++assert.ok(/Authorization/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'Bearer auth needs Authorization allowed');++assert.ok(/Content-Type/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'application/sdp needs Content-Type allowed');++++// Preflight from a foreign origin is answered directly with the open headers.++const preflightRes = makeRes();++let preflightNext = false;++whipCors({ method: 'OPTIONS', headers: { origin: 'https://static.example' } }, preflightRes, () => { preflightNext = true; });++assert.strictEqual(preflightRes.statusCode, 204);++assert.strictEqual(preflightRes.ended, true);++assert.strictEqual(preflightNext, false, 'preflight must not fall through to the route handlers');++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Origin'], '*');++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Methods'], WHIP_CORS_HEADERS['Access-Control-Allow-Methods']);++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Headers'], WHIP_CORS_HEADERS['Access-Control-Allow-Headers']);++++// The actual POST/PATCH/DELETE gets the headers stamped up-front and continues, so even++// an error response from the handler (401 invalid key, 406 no codecs…) is readable++// cross-origin instead of surfacing as an opaque "TypeError: Failed to fetch".++for (const method of ['POST', 'PATCH', 'DELETE']) {++ const r = makeRes();++ let nextCalled = false;++ whipCors({ method, headers: { origin: 'https://static.example' } }, r, () => { nextCalled = true; });++ assert.strictEqual(nextCalled, true, `${method} must reach the handler`);++ assert.strictEqual(r.ended, false);++ assert.strictEqual(r.headers['Access-Control-Allow-Origin'], '*');++ assert.strictEqual(r.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');++}++++console.log('✅ WHIP cross-origin (browser publisher) CORS regression test passed');++const actpassDtls = _extractDtlsParameters({version: 0,origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },@@@ -162,3 +202,51 @@ assert.ok(answerSdp.includes('sha-256'));assert.ok(answerSdp.includes('AA:BB:CC:DD'));console.log('✅ WHIP handler DTLS role and fingerprint regression test passed');++++// ── Browser publishers: rejected m-sections must stay out of the BUNDLE group ──++// A browser offer can carry an m-section we cannot accept (a data channel, or a codec++// the router lacks). RFC 8843 §7.3.3 says a rejected section must not be listed in++// BUNDLE and Chrome/Firefox refuse the whole answer if it is; OBS never checked.++const mixedAnswer = _buildSdpAnswer(++ {++ iceParameters: { usernameFragment: 'ufrag', password: 'pwd' },++ iceCandidates: [++ { foundation: '1', protocol: 'udp', priority: 2130706432, ip: '1.2.3.4', port: 1234, type: 'host' },++ ],++ dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB:CC:DD' }] },++ },++ {++ version: 0,++ origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },++ name: 'test',++ timing: { start: 0, stop: 0 },++ media: [++ {++ type: 'audio', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '111', mid: '0', setup: 'actpass',++ connection: { ip: '127.0.0.1', version: 4 },++ rtp: [{ payload: 111, codec: 'opus', rate: 48000, encoding: 2 }], fmtp: [], rtcpFb: [], ext: [],++ ssrcs: [{ id: '1234', attribute: 'cname' }],++ },++ {++ type: 'video', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '96 97', mid: '1', setup: 'actpass',++ connection: { ip: '127.0.0.1', version: 4 },++ rtp: [{ payload: 96, codec: 'VP8', rate: 90000 }, { payload: 97, codec: 'rtx', rate: 90000 }], fmtp: [], rtcpFb: [], ext: [],++ ssrcs: [{ id: '5678', attribute: 'cname' }],++ },++ {++ type: 'application', protocol: 'UDP/DTLS/SCTP', payloads: 'webrtc-datachannel', mid: '2', setup: 'actpass',++ connection: { ip: '127.0.0.1', version: 4 },++ },++ ],++ },++ {++ audio: { rtpParameters: { codecs: [{ payloadType: 111, mimeType: 'audio/opus', clockRate: 48000, channels: 2 }], headerExtensions: [], encodings: [{ ssrc: 1234 }] } },++ video: { rtpParameters: { codecs: [{ payloadType: 96, mimeType: 'video/VP8', clockRate: 90000 }], headerExtensions: [], encodings: [{ ssrc: 5678 }] } },++ }++);++assert.ok(mixedAnswer.includes('a=group:BUNDLE 0 1\r\n'), `BUNDLE must list only accepted mids, got: ${mixedAnswer.match(/a=group:[^\r\n]*/)?.[0]}`);++assert.ok(mixedAnswer.includes('m=application 0 UDP/DTLS/SCTP webrtc-datachannel'), 'rejected section keeps its offered format list');++assert.ok(mixedAnswer.includes('a=mid:2'), 'rejected section keeps its mid so the m-line count still matches the offer');++assert.strictEqual((mixedAnswer.match(/^m=/gm) || []).length, 3, 'answer must have one m-line per offered m-line');++++console.log('✅ WHIP answer BUNDLE group excludes rejected m-sections (browser publisher) test passed');
@@ -65,6 +65,7 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
@@ -65,6 +65,7 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
+- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
-- WHIP/HTTP ingestion support (`whip.openvibe.live`).
+- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
- real-time broadcast and control channels.
- real-time broadcast and control channels.
### Media (VODs / clips / pastes / thumbnails)
### Media (VODs / clips / pastes / thumbnails)
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
## Getting Started
## Getting Started
- [Onboarding Guide](onboarding.md) — New user walkthrough
- [Onboarding Guide](onboarding.md) — New user walkthrough
-- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, and JSMPEG
+- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, WHIP, and JSMPEG
+- [WHIP Ingest API](whip.md) — WHIP endpoint reference, CORS, error codes · [Publishing from a browser](whip.md#publishing-from-a-browser) (static site, no backend)
## Features
## Features
- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS
- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Broadcasting Guide
# Broadcasting Guide
-OpenVibe.Live supports three live broadcast protocols, each suited to different use cases.
+OpenVibe.Live supports four live broadcast methods, each suited to different use cases.
## Broadcast Methods
## Broadcast Methods
@@ -16,6 +16,12 @@ OpenVibe.Live supports three live broadcast protocols, each suited to different
@@ -16,6 +16,12 @@ OpenVibe.Live supports three live broadcast protocols, each suited to different
- **Setup**: Copy Server URL and Stream Key into OBS
- **Setup**: Copy Server URL and Stream Key into OBS
- **Features**: Full OBS feature set, live thumbnail preview on broadcast page
- **Features**: Full OBS feature set, live thumbnail preview on broadcast page
+### WHIP (OBS, FFmpeg, GStreamer — or any web page)
+- **Best for**: WebRTC-native encoders and custom publishers, including browser-only sites
+- **Latency**: Sub-second
+- **Setup**: Choose *Streaming method: WHIP*, copy the WHIP URL into OBS (Service: WHIP) or `ffmpeg -f whip`
+- **Features**: VP8/H.264 + Opus, auto-creates the live session on first POST, open CORS so a static page can publish with `RTCPeerConnection` + `fetch()` — see the [WHIP Ingest API](whip.md) / [Publishing from a browser](whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html)
+
### JSMPEG (FFmpeg)
### JSMPEG (FFmpeg)
- **Best for**: Headless/embedded streaming (Raspberry Pi, IP cameras, 3D printers)
- **Best for**: Headless/embedded streaming (Raspberry Pi, IP cameras, 3D printers)
- **Latency**: ~1 second
- **Latency**: ~1 second
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+# WHIP Ingest API
+
+OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol, [RFC 9725](https://www.rfc-editor.org/rfc/rfc9725)). Anything that can produce a WebRTC offer can go live with a single HTTP request — OBS 30+, FFmpeg 7+, GStreamer, hardware encoders, **and a plain web page**.
+
+> **Can a frontend-only site stream to WHIP?** Yes. The endpoint is open to every origin (CORS `*`) and authenticates with your stream key, so a static page with no backend can publish straight from the browser using only `RTCPeerConnection` + `fetch()`. Jump to [Publishing from a browser](#publishing-from-a-browser), or try the hosted single-file demo at **<https://openvibe.live/whip-publisher.html>** (view-source to copy it).
+
+## Endpoint
+
+```
+POST https://whip.openvibe.live/whip/{streamKey}
+Content-Type: application/sdp
+```
+
+| URL form | Credential | Use it when |
+|---|---|---|
+| `/whip/{streamKey}` | the key is in the path (optionally also as `Authorization: Bearer {streamKey}` — OBS sends both) | **Default.** Browsers, FFmpeg, GStreamer, OBS. |
+| `/whip/{slotId}` | `Authorization: Bearer {streamKey}` or `?key={streamKey}` | Tools that want a stable, non-secret URL with the secret in a header. |
+| `/whip/{streamSessionId}` | `Authorization: Bearer {openvibe.network JWT}` | Internal / legacy. Requires an already-live session; `hbt_` API tokens are **not** accepted here. |
+
+`{streamKey}` is a 32-char hex string that belongs to one **stream slot** (a "managed stream"). Posting to it auto-creates a live session on that slot if none exists — you do not need to click *Go Live* first. The slot's streaming method must be **Browser** or **WHIP** (protocol `webrtc`); RTMP/JSMPEG slots answer `409 wrong_protocol`.
+
+### Getting your WHIP URL
+
+- **UI:** *Broadcast → your stream → Streaming method: WHIP* → copy the WHIP URL. (Same URL the OBS instructions show.)
+- **API:** `GET /api/streams/managed/{slotId}/profile` (JWT or `hbt_` token) returns `stream_key` and `whip_url_base`; the URL is `${whip_url_base}/whip/${stream_key}`.
+
+Treat the URL as a password: anyone holding it can broadcast to your channel. Regenerate the key from the dashboard if it leaks.
+
+## Session lifecycle
+
+1. **`POST /whip/{streamKey}`** with the SDP offer as the body.
+ `201 Created` · body is the SDP answer (`Content-Type: application/sdp`) · `Location` header is the **session resource URL** — keep it.
+2. Apply the answer, and ICE/DTLS connect. The stream is shown as live once media flows (the *Live* state in your app corresponds to `RTCPeerConnection.connectionState === 'connected'`).
+3. **`PATCH {Location}`** (`Content-Type: application/trickle-ice-sdpfrag`) — accepted and acknowledged with `204`, but **not required**: the server is an ICE-lite agent and includes all of its candidates in the answer, so a client never needs to trickle. Browsers can post the offer immediately after `setLocalDescription()` without waiting for gathering.
+4. **`DELETE {Location}`** — ends the session. When the last session on a stream is gone, the stream ends (VOD finalises, restreams stop, viewers are notified). Sessions also end on their own if ICE fails, or 15 s after ICE reports `disconnected` without recovering.
+
+Posting a second offer to the same slot replaces the previous session (useful for reconnecting after a network change).
+
+## Media requirements
+
+| | Accepted | Notes |
+|---|---|---|
+| Video | **VP8**, **H.264** (Constrained Baseline, `profile-level-id=42e01f`, `packetization-mode=1`) | The server takes the **first** codec in your offer that it supports. AV1 / VP9 / HEVC are skipped; if nothing else is offered you get `406 no_compatible_codecs`. |
+| Audio | **Opus** 48 kHz (mono or stereo) | |
+| Encodings | one per m-section (SSRC or a single RID) | No simulcast / SVC — offer a single encoding. RTX (`a=ssrc-group:FID`) is supported. |
+| Transport | BUNDLE, `rtcp-mux`, DTLS-SRTP | Offer `a=setup:actpass`; the answer is `a=setup:passive` + `a=ice-lite`. A data-channel m-section is rejected (`port 0`) but does not fail the offer. |
+
+No STUN/TURN is needed: the server sits on a public address and the client connects straight to the candidates in the answer. Outbound UDP must be allowed.
+
+## Errors
+
+Errors are JSON (`{ "error": "...", "error_code": "..." }`) and also carry the code in an `X-WHIP-ERROR` header, which is exposed to cross-origin callers.
+
+| Status | `X-WHIP-ERROR` | Meaning |
+|---|---|---|
+| 400 | `missing_sdp` / `invalid_sdp` / `invalid_rtp_encoding` / `invalid_stream_id` | Body is not a usable SDP offer (e.g. a video section with no SSRC or RID). |
+| 401 | `invalid_stream_key` / `bearer_mismatch` / `invalid_key` / `invalid_token` / `authentication_required` / `user_not_found` | Key or token not recognised, or path key ≠ bearer key. |
+| 403 | `user_banned` / `not_your_stream` | |
+| 404 | `slot_not_found` / `stream_not_found` / `session_not_found` | The last one is a `PATCH`/`DELETE` to a resource that already ended. |
+| 406 | `no_compatible_codecs` | Offer only VP8 / H.264 / Opus. |
+| 409 | `wrong_protocol` / `stream_not_live` | Slot is set to RTMP/JSMPEG (change the streaming method), or (JWT form) session is not live. |
+| 502 | `transport_creation_failed` / `dtls_negotiation_failed` / `producer_creation_failed` | SFU could not set up the session — retry. |
+| 503 | `sfu_unavailable` / `whip_unavailable` | Server-side WebRTC is down. |
+
+## Cross-origin (CORS)
+
+Every `/whip/*` response, including errors, carries:
+
+```
+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
+```
+
+There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:
+
+```bash
+curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
+ -H 'Origin: https://my-static-site.example' \
+ -H 'Access-Control-Request-Method: POST' \
+ -H 'Access-Control-Request-Headers: content-type'
+# → HTTP/2 204 with the headers above
+```
+
+## Publishing from a browser
+
+> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>
+> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.
+
+**Spec in five lines (paste-ready):**
+
+```text
+OpenVibe.Live WHIP ingest — browser publisher requirements
+1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
+2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).
+3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.
+4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.
+5. Stop with DELETE <Location> (use fetch keepalive on pagehide). Errors: JSON + X-WHIP-ERROR header (see table above).
+```
+
+A complete publisher is about forty lines. This is the core of [`whip-publisher.html`](../public/whip-publisher.html) (hosted at <https://openvibe.live/whip-publisher.html>), which adds a UI, screen-share, bitrate stats and clean-up on tab close:
+
+```html
+<video id="preview" autoplay muted playsinline></video>
+<button id="go">Go live</button>
+<script>
+const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';
+let pc, resourceUrl;
+
+async function goLive() {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: true,
+ });
+ document.getElementById('preview').srcObject = stream;
+
+ pc = new RTCPeerConnection({ iceServers: [] }); // ICE-lite server: no STUN needed
+ for (const track of stream.getTracks()) {
+ pc.addTransceiver(track, {
+ direction: 'sendonly',
+ sendEncodings: track.kind === 'video' ? [{ maxBitrate: 2_500_000 }] : undefined,
+ });
+ }
+ pc.onconnectionstatechange = () => console.log('connection:', pc.connectionState);
+
+ await pc.setLocalDescription(await pc.createOffer()); // no need to wait for ICE gathering
+
+ const res = await fetch(WHIP_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/sdp' },
+ body: pc.localDescription.sdp,
+ });
+ if (res.status !== 201) {
+ throw new Error(`WHIP ${res.status} ${res.headers.get('X-WHIP-ERROR') || ''}`);
+ }
+ resourceUrl = new URL(res.headers.get('Location'), WHIP_URL).href;
+ await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
+}
+
+async function stop() {
+ if (resourceUrl) await fetch(resourceUrl, { method: 'DELETE', keepalive: true });
+ pc?.close();
+}
+
+document.getElementById('go').onclick = () => goLive().catch(console.error);
+addEventListener('pagehide', () => resourceUrl && fetch(resourceUrl, { method: 'DELETE', keepalive: true }));
+</script>
+```
+
+Things worth knowing:
+
+- **Secure context.** `getUserMedia` only works on `https://`, `localhost`, or a local `file://` — plain `http://` hosting will not get camera access.
+- **Screen share.** Swap `getUserMedia` for `navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })`. Listen for the video track's `ended` event — that is the browser's own *Stop sharing* button.
+- **Codec choice.** Chrome and Firefox offer VP8 first, Safari offers H.264 first; both are accepted as-is. If you use `RTCRtpTransceiver.setCodecPreferences()`, keep VP8 or H.264 at the front — the server negotiates the first supported codec it sees.
+- **Bitrate.** Set `maxBitrate` in `sendEncodings` (or later via `sender.setParameters()`); 2.5 Mbps for 720p30 and 4.5 Mbps for 1080p30 are good defaults.
+- **Reconnecting.** On `connectionState === 'failed'`, build a fresh `RTCPeerConnection` and `POST` again — the new session replaces the old one on the same slot, and viewers pick up automatically.
+- **Ending cleanly.** Always `DELETE` the resource (use `keepalive: true` so it survives tab close). If you don't, the stream lingers until the 15-second ICE grace period expires.
+- **The key is visible in the page.** That is inherent to a backend-less publisher and the same trust model as OBS; keep the page private (or have the streamer paste their own key at runtime, as the hosted demo does) rather than committing a key into a public repo.
+
+## Other clients
+
+- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.
+- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`
+- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
+
+The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.
new file mode 100644
new file mode 100644
@@ -0,0 +1,378 @@
@@ -0,0 +1,378 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Browser WHIP Publisher — OpenVibe.Live</title>
+ <!--
+ OpenVibe.Live — browser-only WHIP publisher.
+
+ This page has NO backend and NO dependencies. It is one self-contained HTML file
+ that publishes your camera + mic (or your screen) to an OpenVibe.Live WHIP endpoint
+ using nothing but RTCPeerConnection and fetch(). Save it, host it anywhere
+ (GitHub Pages, Cloudflare Pages, Netlify, a local file://), or lift the script
+ into your own site.
+
+ Protocol walkthrough + API reference:
+ https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md
+
+ Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })
+ window.OpenVibeWhip.stop()
+ window.OpenVibeWhip.state → { phase, connectionState, resourceUrl, error }
+ -->
+ <style>
+ :root {
+ --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --bg-input: #12121a;
+ --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
+ --accent: #8b5cf6; --accent-light: #a78bfa; --accent-dark: #6d28d9;
+ --ok: #2ecc71; --warn: #f1c40f; --bad: #e74c3c; --border: #2a2a32;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+ a { color: var(--accent-light); }
+ main { max-width: 900px; margin: 0 auto; padding: 24px 16px 48px; }
+ h1 { font-size: 1.35rem; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
+ h1 .logo { width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); display: inline-block; }
+ .sub { color: var(--text-2); margin: 0 0 20px; font-size: 0.92rem; }
+ .card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
+ label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.04em; }
+ input, select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text); font: inherit; }
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+ @media (max-width: 600px) { .row { grid-template-columns: 1fr; } }
+ .actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
+ button { padding: 10px 18px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-weight: 600; cursor: pointer; }
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
+ .btn-go { background: var(--accent); color: #fff; }
+ .btn-go:not(:disabled):hover { background: var(--accent-dark); }
+ .btn-stop { background: transparent; color: var(--bad); border-color: var(--bad); }
+ .btn-stop:not(:disabled):hover { background: rgba(231, 76, 60, 0.12); }
+ .pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: var(--bg-3); font-size: 0.85rem; color: var(--text-2); }
+ .pill::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
+ .pill[data-phase="connecting"]::before, .pill[data-phase="starting"]::before { background: var(--warn); }
+ .pill[data-phase="live"]::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(46, 204, 113, 0.18); }
+ .pill[data-phase="error"]::before { background: var(--bad); }
+ video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; display: block; }
+ .stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 10px; font-size: 0.85rem; color: var(--text-2); font-variant-numeric: tabular-nums; }
+ .stats b { color: var(--text); font-weight: 600; }
+ pre.log { margin: 0; max-height: 220px; overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
+ pre.log .err { color: var(--bad); } pre.log .ok { color: var(--ok); }
+ .hint { font-size: 0.82rem; color: var(--muted); margin-top: 6px; }
+ .hint code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; color: var(--text-2); }
+ </style>
+</head>
+<body>
+<main>
+ <h1><span class="logo"></span> Browser WHIP Publisher</h1>
+ <p class="sub">Go live on OpenVibe.Live from any web page — no OBS, no backend, no libraries. Just this file.</p>
+
+ <div class="card">
+ <label for="whip-url">WHIP URL</label>
+ <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">
+ <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
+
+ <label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
+ <input id="whip-token" type="password" placeholder="stream key" autocomplete="off">
+
+ <div class="row">
+ <div>
+ <label for="whip-source">Source</label>
+ <select id="whip-source">
+ <option value="camera">Camera + microphone</option>
+ <option value="screen">Screen / window (+ system audio if offered)</option>
+ </select>
+ </div>
+ <div>
+ <label for="whip-quality">Quality</label>
+ <select id="whip-quality">
+ <option value="1280x720@2500">720p · 2.5 Mbps</option>
+ <option value="1920x1080@4500">1080p · 4.5 Mbps</option>
+ <option value="854x480@1200">480p · 1.2 Mbps</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="actions">
+ <button id="whip-start" class="btn-go">Go live</button>
+ <button id="whip-stop" class="btn-stop" disabled>Stop</button>
+ <span id="whip-status" class="pill" data-phase="idle">Idle</span>
+ </div>
+ </div>
+
+ <div class="card">
+ <video id="whip-preview" autoplay muted playsinline></video>
+ <div class="stats">
+ <span>Bitrate <b id="st-bitrate">–</b></span>
+ <span>FPS <b id="st-fps">–</b></span>
+ <span>Resolution <b id="st-res">–</b></span>
+ <span>Codec <b id="st-codec">–</b></span>
+ <span>Uptime <b id="st-uptime">–</b></span>
+ </div>
+ </div>
+
+ <div class="card">
+ <pre id="whip-log" class="log">Ready.</pre>
+ </div>
+
+ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>
+</main>
+
+<script>
+(() => {
+ 'use strict';
+
+ const $ = (id) => document.getElementById(id);
+ const els = {
+ url: $('whip-url'), token: $('whip-token'), source: $('whip-source'), quality: $('whip-quality'),
+ start: $('whip-start'), stop: $('whip-stop'), status: $('whip-status'), preview: $('whip-preview'), log: $('whip-log'),
+ bitrate: $('st-bitrate'), fps: $('st-fps'), res: $('st-res'), codec: $('st-codec'), uptime: $('st-uptime'),
+ };
+ const STORAGE_KEY = 'openvibe-whip-url';
+
+ const state = {
+ phase: 'idle', // idle | starting | connecting | live | error
+ connectionState: null,
+ resourceUrl: null, // WHIP resource (Location header) — DELETE it to end the session
+ error: null,
+ pc: null,
+ stream: null,
+ startedAt: null,
+ statsTimer: null,
+ lastStats: null,
+ };
+
+ // ── UI helpers ─────────────────────────────────────────────
+ function log(message, cls) {
+ const line = document.createElement('div');
+ if (cls) line.className = cls;
+ line.textContent = `${new Date().toLocaleTimeString()} ${message}`;
+ els.log.appendChild(line);
+ els.log.scrollTop = els.log.scrollHeight;
+ }
+
+ function setPhase(phase, label) {
+ state.phase = phase;
+ els.status.dataset.phase = phase;
+ els.status.textContent = label || phase[0].toUpperCase() + phase.slice(1);
+ const busy = phase === 'starting' || phase === 'connecting' || phase === 'live';
+ els.start.disabled = busy;
+ els.stop.disabled = !busy;
+ for (const el of [els.url, els.token, els.source, els.quality]) el.disabled = busy;
+ }
+
+ function resetStats() {
+ for (const el of [els.bitrate, els.fps, els.res, els.codec, els.uptime]) el.textContent = '–';
+ }
+
+ // ── Media ──────────────────────────────────────────────────
+ async function getSource(kind, width, height) {
+ if (kind === 'screen') {
+ return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ }
+ return navigator.mediaDevices.getUserMedia({
+ video: { width: { ideal: width }, height: { ideal: height }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
+ });
+ }
+
+ // The server negotiates the FIRST offered codec it supports (VP8, H.264 or Opus), so
+ // put the ones it can take at the front. Browsers already default to VP8/H.264 first,
+ // this just makes it explicit and future-proof against AV1/VP9-first defaults.
+ function preferCodecs(transceiver, kind) {
+ try {
+ if (!transceiver.setCodecPreferences || !RTCRtpSender.getCapabilities) return;
+ const caps = RTCRtpSender.getCapabilities(kind);
+ if (!caps) return;
+ const wanted = kind === 'video' ? ['video/vp8', 'video/h264'] : ['audio/opus'];
+ const rank = (c) => { const i = wanted.indexOf(c.mimeType.toLowerCase()); return i === -1 ? wanted.length : i; };
+ transceiver.setCodecPreferences([...caps.codecs].sort((a, b) => rank(a) - rank(b)));
+ } catch { /* optional optimisation */ }
+ }
+
+ // ── WHIP ───────────────────────────────────────────────────
+ async function start(opts = {}) {
+ if (state.phase !== 'idle' && state.phase !== 'error') throw new Error('Already publishing');
+
+ const url = String(opts.url ?? els.url.value).trim();
+ const token = String(opts.token ?? els.token.value).trim();
+ const source = opts.source || els.source.value;
+ const [dims, kbps] = (opts.quality || els.quality.value).split('@');
+ const [width, height] = dims.split('x').map(Number);
+ const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;
+
+ if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {
+ throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');
+ }
+ try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }
+
+ state.error = null;
+ state.resourceUrl = null;
+ resetStats();
+ setPhase('starting', 'Requesting media…');
+
+ try {
+ const stream = await getSource(source, width, height);
+ state.stream = stream;
+ els.preview.srcObject = stream;
+ for (const track of stream.getTracks()) {
+ log(`${track.kind} track: ${track.label || 'unnamed'}`);
+ // "Stop sharing" in the browser's screen-share bar ends the track → end the session.
+ track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });
+ }
+
+ // OpenVibe's SFU is an ICE-lite server on a public address, so no STUN/TURN is
+ // needed: the browser connects straight to the candidates in the SDP answer.
+ const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] });
+ state.pc = pc;
+
+ for (const track of stream.getTracks()) {
+ const init = { direction: 'sendonly', streams: [stream] };
+ if (track.kind === 'video') init.sendEncodings = [{ maxBitrate }]; // one encoding — no simulcast
+ const transceiver = pc.addTransceiver(track, init);
+ preferCodecs(transceiver, track.kind);
+ }
+
+ pc.addEventListener('connectionstatechange', () => {
+ state.connectionState = pc.connectionState;
+ log(`connection: ${pc.connectionState}`);
+ if (pc.connectionState === 'connected') {
+ if (!state.startedAt) state.startedAt = Date.now();
+ setPhase('live', 'Live');
+ log('Media is flowing — you are live.', 'ok');
+ } else if (pc.connectionState === 'failed') {
+ stop('connection failed');
+ fail(new Error('WebRTC connection failed (ICE/DTLS). Check that UDP is not blocked by a firewall.'));
+ } else if (pc.connectionState === 'disconnected' && state.phase === 'live') {
+ setPhase('connecting', 'Reconnecting…');
+ }
+ });
+
+ setPhase('connecting', 'Negotiating…');
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ const headers = { 'Content-Type': 'application/sdp' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ log(`POST ${url}`);
+ const resp = await fetch(url, { method: 'POST', headers, body: pc.localDescription.sdp });
+
+ if (resp.status !== 201) {
+ const code = resp.headers.get('X-WHIP-ERROR');
+ let detail = '';
+ try { detail = (await resp.json()).error || ''; } catch { /* non-JSON */ }
+ throw new Error(`WHIP endpoint answered HTTP ${resp.status}${code ? ` (${code})` : ''}${detail ? `: ${detail}` : ''}`);
+ }
+
+ // Location is the session resource. It may be absolute or relative — resolve it
+ // against the endpoint URL so DELETE always hits the right host.
+ const location = resp.headers.get('Location');
+ state.resourceUrl = location ? new URL(location, url).toString() : null;
+ log(`session created${state.resourceUrl ? `: ${state.resourceUrl}` : ''}`);
+
+ const answer = await resp.text();
+ await pc.setRemoteDescription({ type: 'answer', sdp: answer });
+ log('answer applied, waiting for ICE/DTLS…');
+ startStats();
+ } catch (err) {
+ await stop('start failed', { silent: true });
+ fail(err);
+ throw err;
+ }
+ }
+
+ async function stop(reason = 'user', { silent = false } = {}) {
+ const { pc, stream, resourceUrl } = state;
+ stopStats();
+ state.pc = null; state.stream = null; state.resourceUrl = null; state.startedAt = null;
+
+ if (resourceUrl) {
+ // keepalive lets the DELETE complete even if the tab is closing.
+ try { await fetch(resourceUrl, { method: 'DELETE', keepalive: true }); log('session deleted'); }
+ catch (err) { log(`DELETE failed: ${err.message}`, 'err'); }
+ }
+ if (pc) { try { pc.close(); } catch { /* */ } }
+ if (stream) for (const t of stream.getTracks()) t.stop();
+ els.preview.srcObject = null;
+ state.connectionState = null;
+
+ if (!silent) {
+ setPhase('idle', 'Idle');
+ log(`stopped (${reason})`);
+ }
+ }
+
+ function fail(err) {
+ state.error = err?.message || String(err);
+ setPhase('error', 'Error');
+ log(state.error, 'err');
+ if (/Permission|NotAllowed/i.test(state.error)) log('Camera/mic access was denied — check the browser permission prompt.', 'err');
+ if (!window.isSecureContext) log('getUserMedia needs a secure context: serve this page over https:// (or open it as a local file / localhost).', 'err');
+ }
+
+ // ── Stats ──────────────────────────────────────────────────
+ function startStats() {
+ stopStats();
+ state.statsTimer = setInterval(async () => {
+ const pc = state.pc;
+ if (!pc) return;
+ let report;
+ try { report = await pc.getStats(); } catch { return; }
+ const now = Date.now();
+ let video = null, codecName = null;
+ const codecs = new Map();
+ for (const s of report.values()) {
+ if (s.type === 'codec') codecs.set(s.id, s.mimeType);
+ if (s.type === 'outbound-rtp' && s.kind === 'video') video = s;
+ }
+ if (video) {
+ if (state.lastStats && video.bytesSent != null) {
+ const kbps = ((video.bytesSent - state.lastStats.bytesSent) * 8) / ((now - state.lastStats.at) / 1000) / 1000;
+ els.bitrate.textContent = `${Math.round(kbps)} kbps`;
+ }
+ state.lastStats = { bytesSent: video.bytesSent, at: now };
+ if (video.framesPerSecond != null) els.fps.textContent = String(Math.round(video.framesPerSecond));
+ if (video.frameWidth) els.res.textContent = `${video.frameWidth}×${video.frameHeight}`;
+ codecName = codecs.get(video.codecId);
+ if (codecName) els.codec.textContent = codecName.replace('video/', '');
+ }
+ if (state.startedAt) {
+ const s = Math.floor((now - state.startedAt) / 1000);
+ els.uptime.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
+ }
+ }, 2000);
+ }
+ function stopStats() {
+ if (state.statsTimer) clearInterval(state.statsTimer);
+ state.statsTimer = null;
+ state.lastStats = null;
+ }
+
+ // ── Wiring ─────────────────────────────────────────────────
+ els.start.addEventListener('click', () => start().catch(() => { /* already surfaced via fail() */ }));
+ els.stop.addEventListener('click', () => stop('user'));
+ window.addEventListener('pagehide', () => {
+ if (state.resourceUrl) fetch(state.resourceUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
+ });
+
+ // Prefill: ?url=… wins, then the last URL used in this browser.
+ const params = new URLSearchParams(location.search);
+ let saved = null;
+ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* */ }
+ els.url.value = params.get('url') || saved || '';
+ if (params.get('token')) els.token.value = params.get('token');
+
+ if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
+ fail(new Error('This browser does not support WebRTC publishing.'));
+ }
+
+ window.OpenVibeWhip = {
+ start,
+ stop,
+ get state() {
+ return { phase: state.phase, connectionState: state.connectionState, resourceUrl: state.resourceUrl, error: state.error };
+ },
+ };
+})();
+</script>
+</body>
+</html>
@@ -212,6 +212,11 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
@@ -212,6 +212,11 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
+// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
+// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different
+// origin from the page itself and would otherwise be blocked by connect-src 'self'.
+const whipConnectOrigin = (config.whip?.enabled && normalizeOrigin(config.whip?.publicUrl)) || null;
+
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
@@ -224,7 +229,7 @@ app.use(helmet({
@@ -224,7 +229,7 @@ app.use(helmet({
// every queued item renders as a broken image, which is what the media
// every queued item renders as a broken image, which is what the media
// request tab was doing for every YouTube link.
// request tab was doing for every YouTube link.
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],
- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],
+ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", ...(whipConnectOrigin ? [whipConnectOrigin] : [])],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// or the browser blocks the media element.
// or the browser blocks the media element.
@@ -260,7 +265,12 @@ function isAllowedOrigin(origin) {
@@ -260,7 +265,12 @@ function isAllowedOrigin(origin) {
} catch { return false; }
} catch { return false; }
}
}
-app.use(cors({
+// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every origin —
+// that is what lets a static, backend-less site publish from the browser (docs/whip.md).
+// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
+app.use('/whip', whipHandler.whipCors);
+
+const allowlistedCors = cors({
origin(origin, callback) {
origin(origin, callback) {
if (!origin) return callback(null, true);
if (!origin) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
@@ -268,7 +278,11 @@ app.use(cors({
@@ -268,7 +278,11 @@ app.use(cors({
return callback(new Error('Origin not allowed by CORS'));
return callback(new Error('Origin not allowed by CORS'));
},
},
credentials: true,
credentials: true,
-}));
+});
+app.use((req, res, next) => {
+ if (req.path === '/whip' || req.path.startsWith('/whip/')) return next();
+ return allowlistedCors(req, res, next);
+});
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
app.use(cookieParser());
@@ -54,10 +54,53 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
@@ -54,10 +54,53 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
}
}
}
}
+// ── CORS ─────────────────────────────────────────────────────
+// WHIP is authenticated by the stream key (path / Bearer / ?key=) and never by a
+// cookie or browser session, so the endpoint can safely be opened to *every* origin.
+// That is what lets a purely static site (GitHub Pages, a local index.html, a
+// Cloudflare Pages app…) publish straight from `RTCPeerConnection` + `fetch()` with
+// no backend of its own — see docs/whip.md. The global CORS middleware in
+// server/index.js is credentialed and allowlisted (it protects the cookie-authenticated
+// /api routes), so /whip/* is exempted from it and handled here instead.
+//
+// `Location` must be exposed or a browser cannot read the resource URL it needs for
+// PATCH/DELETE; `X-WHIP-ERROR` is exposed so a client can show the machine-readable
+// error code instead of a bare status.
+const WHIP_CORS_HEADERS = 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',
+ 'Access-Control-Max-Age': '86400',
+ // Helmet defaults CORP to same-origin; a CORS-mode fetch is not subject to it,
+ // but be explicit so nothing upstream can turn the response opaque.
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+});
+
+function applyWhipCorsHeaders(res) {
+ for (const [name, value] of Object.entries(WHIP_CORS_HEADERS)) {
+ res.set(name, value);
+ }
+ return res;
+}
+
+/**
+ * Express middleware for everything under /whip. Stamps the open CORS headers on the
+ * response up-front (so error responses from the handlers carry them too) and answers
+ * preflights directly.
+ */
+function whipCors(req, res, next) {
+ applyWhipCorsHeaders(res);
+ if (req.method === 'OPTIONS') {
+ return res.status(204).end();
+ }
+ next();
+}
+
function buildWhipResponseHeaders(req, streamId, resourceId) {
function buildWhipResponseHeaders(req, streamId, resourceId) {
return {
return {
Location: buildWhipResourceUrl(req, streamId, resourceId),
Location: buildWhipResourceUrl(req, streamId, resourceId),
- 'Access-Control-Expose-Headers': 'Location',
+ 'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],
};
};
}
}
@@ -403,7 +446,11 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
@@ -403,7 +446,11 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
const { iceParameters, iceCandidates, dtlsParameters } = transport;
const { iceParameters, iceCandidates, dtlsParameters } = transport;
const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
- const mids = [];
+ // Only *accepted* m-sections go in the BUNDLE group. RFC 8843 §7.3.3 forbids listing a
+ // rejected (port 0) section there, and browsers enforce it — Chrome/Firefox refuse the
+ // whole answer, which is fatal for a browser publisher whose extra m-section (e.g. a
+ // data channel, or a codec the router lacks) we cannot accept. OBS never checked.
+ const bundledMids = [];
const serverAddress = iceCandidates?.[0]?.ip || config.mediasoup.announcedIp || '127.0.0.1';
const serverAddress = iceCandidates?.[0]?.ip || config.mediasoup.announcedIp || '127.0.0.1';
const sdpObj = {
const sdpObj = {
@@ -417,9 +464,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
@@ -417,9 +464,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
media: [],
media: [],
};
};
- for (const offerMedia of offerSdp.media || []) {
- const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mids.length);
- mids.push(mid);
+ for (const [mediaIndex, offerMedia] of (offerSdp.media || []).entries()) {
+ const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mediaIndex);
const producer = producersByKind[offerMedia.type];
const producer = producersByKind[offerMedia.type];
if (!producer) {
if (!producer) {
@@ -427,12 +473,16 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
@@ -427,12 +473,16 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
type: offerMedia.type,
type: offerMedia.type,
port: 0,
port: 0,
protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
- payloads: '0',
+ // Echo the offered format list: a rejected section must still be
+ // syntactically complete, and for m=application the "payload" is the
+ // literal `webrtc-datachannel` token, not a number.
+ payloads: String(offerMedia.payloads || '0'),
mid,
mid,
direction: 'inactive',
direction: 'inactive',
});
});
continue;
continue;
}
}
+ bundledMids.push(mid);
const answerMedia = {
const answerMedia = {
type: offerMedia.type,
type: offerMedia.type,
@@ -498,8 +548,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
@@ -498,8 +548,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
sdpObj.media.push(answerMedia);
sdpObj.media.push(answerMedia);
}
}
- if (mids.length > 0) {
- sdpObj.groups.push({ type: 'BUNDLE', mids: mids.join(' ') });
+ if (bundledMids.length > 0) {
+ sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });
}
}
return sdpTransform.write(sdpObj);
return sdpTransform.write(sdpObj);
@@ -1030,11 +1080,7 @@ function handleWhipDelete(req, res) {
@@ -1030,11 +1080,7 @@ function handleWhipDelete(req, res) {
* OPTIONS /whip/* — CORS/discovery
* OPTIONS /whip/* — CORS/discovery
*/
*/
function handleWhipOptions(req, res) {
function handleWhipOptions(req, res) {
- res.status(204)
- .set('Access-Control-Allow-Methods', 'POST, PATCH, DELETE, OPTIONS')
- .set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
- .set('Access-Control-Expose-Headers', 'Location')
- .end();
+ applyWhipCorsHeaders(res.status(204)).end();
}
}
/**
/**
@@ -1101,6 +1147,9 @@ module.exports = {
@@ -1101,6 +1147,9 @@ module.exports = {
handleWhipPatch,
handleWhipPatch,
handleWhipDelete,
handleWhipDelete,
handleWhipOptions,
handleWhipOptions,
+ whipCors,
+ applyWhipCorsHeaders,
+ WHIP_CORS_HEADERS,
buildWhipResourceUrl,
buildWhipResourceUrl,
buildWhipResponseHeaders,
buildWhipResponseHeaders,
hasSfuProducers,
hasSfuProducers,
@@ -41,7 +41,7 @@ assert.notDeepStrictEqual(rtpParameters.encodings[0], {}, 'Encoding object must
@@ -41,7 +41,7 @@ assert.notDeepStrictEqual(rtpParameters.encodings[0], {}, 'Encoding object must
console.log('✅ WHIP handler RTP encoding regression test passed');
console.log('✅ WHIP handler RTP encoding regression test passed');
-const { buildWhipResponseHeaders, handleWhipOptions } = require('../server/streaming/whip-handler');
+const { buildWhipResponseHeaders, handleWhipOptions, whipCors, WHIP_CORS_HEADERS } = require('../server/streaming/whip-handler');
const req = {
const req = {
protocol: 'https',
protocol: 'https',
@@ -50,26 +50,66 @@ const req = {
@@ -50,26 +50,66 @@ const req = {
const headers = buildWhipResponseHeaders(req, '123', 'resource-abc');
const headers = buildWhipResponseHeaders(req, '123', 'resource-abc');
assert.strictEqual(headers.Location, 'http://localhost:3000/whip/123/resource-abc');
assert.strictEqual(headers.Location, 'http://localhost:3000/whip/123/resource-abc');
-assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location');
+// A browser can only read the resource URL (and our error code) if both are exposed.
+assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
assert.ok(!Object.prototype.hasOwnProperty.call(headers, 'Link'));
assert.ok(!Object.prototype.hasOwnProperty.call(headers, 'Link'));
-const res = {
- statusCode: null,
- headers: {},
- ended: false,
- status(code) { this.statusCode = code; return this; },
- set(key, value) { this.headers[key] = value; return this; },
- end() { this.ended = true; },
-};
+function makeRes() {
+ return {
+ statusCode: null,
+ headers: {},
+ ended: false,
+ status(code) { this.statusCode = code; return this; },
+ set(key, value) { this.headers[key] = value; return this; },
+ end() { this.ended = true; },
+ };
+}
+const res = makeRes();
handleWhipOptions({}, res);
handleWhipOptions({}, res);
assert.strictEqual(res.statusCode, 204);
assert.strictEqual(res.statusCode, 204);
-assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location');
+assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
assert.ok(!('Link' in res.headers));
assert.ok(!('Link' in res.headers));
assert.strictEqual(res.ended, true);
assert.strictEqual(res.ended, true);
console.log('✅ WHIP handler response header regression test passed');
console.log('✅ WHIP handler response header regression test passed');
+// ── Browser (cross-origin) publishing: /whip must be open to every origin ──
+// A static site with no backend can only publish if the preflight and the POST both
+// come back with Access-Control-Allow-Origin: * — the stream key is the credential.
+assert.strictEqual(WHIP_CORS_HEADERS['Access-Control-Allow-Origin'], '*');
+for (const method of ['POST', 'PATCH', 'DELETE']) {
+ assert.ok(WHIP_CORS_HEADERS['Access-Control-Allow-Methods'].includes(method), `CORS must allow ${method}`);
+}
+assert.ok(/Authorization/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'Bearer auth needs Authorization allowed');
+assert.ok(/Content-Type/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'application/sdp needs Content-Type allowed');
+
+// Preflight from a foreign origin is answered directly with the open headers.
+const preflightRes = makeRes();
+let preflightNext = false;
+whipCors({ method: 'OPTIONS', headers: { origin: 'https://static.example' } }, preflightRes, () => { preflightNext = true; });
+assert.strictEqual(preflightRes.statusCode, 204);
+assert.strictEqual(preflightRes.ended, true);
+assert.strictEqual(preflightNext, false, 'preflight must not fall through to the route handlers');
+assert.strictEqual(preflightRes.headers['Access-Control-Allow-Origin'], '*');
+assert.strictEqual(preflightRes.headers['Access-Control-Allow-Methods'], WHIP_CORS_HEADERS['Access-Control-Allow-Methods']);
+assert.strictEqual(preflightRes.headers['Access-Control-Allow-Headers'], WHIP_CORS_HEADERS['Access-Control-Allow-Headers']);
+
+// The actual POST/PATCH/DELETE gets the headers stamped up-front and continues, so even
+// an error response from the handler (401 invalid key, 406 no codecs…) is readable
+// cross-origin instead of surfacing as an opaque "TypeError: Failed to fetch".
+for (const method of ['POST', 'PATCH', 'DELETE']) {
+ const r = makeRes();
+ let nextCalled = false;
+ whipCors({ method, headers: { origin: 'https://static.example' } }, r, () => { nextCalled = true; });
+ assert.strictEqual(nextCalled, true, `${method} must reach the handler`);
+ assert.strictEqual(r.ended, false);
+ assert.strictEqual(r.headers['Access-Control-Allow-Origin'], '*');
+ assert.strictEqual(r.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
+}
+
+console.log('✅ WHIP cross-origin (browser publisher) CORS regression test passed');
+
const actpassDtls = _extractDtlsParameters({
const actpassDtls = _extractDtlsParameters({
version: 0,
version: 0,
origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
@@ -162,3 +202,51 @@ assert.ok(answerSdp.includes('sha-256'));
@@ -162,3 +202,51 @@ assert.ok(answerSdp.includes('sha-256'));
assert.ok(answerSdp.includes('AA:BB:CC:DD'));
assert.ok(answerSdp.includes('AA:BB:CC:DD'));
console.log('✅ WHIP handler DTLS role and fingerprint regression test passed');
console.log('✅ WHIP handler DTLS role and fingerprint regression test passed');
+
+// ── Browser publishers: rejected m-sections must stay out of the BUNDLE group ──
+// A browser offer can carry an m-section we cannot accept (a data channel, or a codec
+// the router lacks). RFC 8843 §7.3.3 says a rejected section must not be listed in
+// BUNDLE and Chrome/Firefox refuse the whole answer if it is; OBS never checked.
+const mixedAnswer = _buildSdpAnswer(
+ {
+ iceParameters: { usernameFragment: 'ufrag', password: 'pwd' },
+ iceCandidates: [
+ { foundation: '1', protocol: 'udp', priority: 2130706432, ip: '1.2.3.4', port: 1234, type: 'host' },
+ ],
+ dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB:CC:DD' }] },
+ },
+ {
+ version: 0,
+ origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
+ name: 'test',
+ timing: { start: 0, stop: 0 },
+ media: [
+ {
+ type: 'audio', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '111', mid: '0', setup: 'actpass',
+ connection: { ip: '127.0.0.1', version: 4 },
+ rtp: [{ payload: 111, codec: 'opus', rate: 48000, encoding: 2 }], fmtp: [], rtcpFb: [], ext: [],
+ ssrcs: [{ id: '1234', attribute: 'cname' }],
+ },
+ {
+ type: 'video', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '96 97', mid: '1', setup: 'actpass',
+ connection: { ip: '127.0.0.1', version: 4 },
+ rtp: [{ payload: 96, codec: 'VP8', rate: 90000 }, { payload: 97, codec: 'rtx', rate: 90000 }], fmtp: [], rtcpFb: [], ext: [],
+ ssrcs: [{ id: '5678', attribute: 'cname' }],
+ },
+ {
+ type: 'application', protocol: 'UDP/DTLS/SCTP', payloads: 'webrtc-datachannel', mid: '2', setup: 'actpass',
+ connection: { ip: '127.0.0.1', version: 4 },
+ },
+ ],
+ },
+ {
+ audio: { rtpParameters: { codecs: [{ payloadType: 111, mimeType: 'audio/opus', clockRate: 48000, channels: 2 }], headerExtensions: [], encodings: [{ ssrc: 1234 }] } },
+ video: { rtpParameters: { codecs: [{ payloadType: 96, mimeType: 'video/VP8', clockRate: 90000 }], headerExtensions: [], encodings: [{ ssrc: 5678 }] } },
+ }
+);
+assert.ok(mixedAnswer.includes('a=group:BUNDLE 0 1\r\n'), `BUNDLE must list only accepted mids, got: ${mixedAnswer.match(/a=group:[^\r\n]*/)?.[0]}`);
+assert.ok(mixedAnswer.includes('m=application 0 UDP/DTLS/SCTP webrtc-datachannel'), 'rejected section keeps its offered format list');
+assert.ok(mixedAnswer.includes('a=mid:2'), 'rejected section keeps its mid so the m-line count still matches the offer');
+assert.strictEqual((mixedAnswer.match(/^m=/gm) || []).length, 3, 'answer must have one m-line per offered m-line');
+
+console.log('✅ WHIP answer BUNDLE group excludes rejected m-sections (browser publisher) test passed');