openvibers/openrestream Commits

Plan T4: the RFC 9725 follow-ups the independent review of PR 5 left… (#6)

OP OpenVibers committed 2 Oct 2026 · af1fb505fe · 6 files, +144 −25

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenRe.Stream/pull/6

Changes

ddiff --git a/.env.example b/.env.example
iindex 54096cd..def50ea 100644
---- a/.env.example
++++ b/.env.example
@@@ -62,6 +62,58 @@ OPENRE_RTMP_PORT=1936
#OPENRE_RTMP_CHUNK_SIZE=60000
#OPENRE_RTMP_MAX_PUBLISHERS=64
++# ── Ingest (JSMPEG, openre-jsmpeg@) ───────────────────────────
++# Public port: the broadcaster's MPEG-TS HTTP POST and the WebSocket viewers on it. Live's relay
++# keeps 9710/9711 until the JSMPEG cutover (README "Port plan").
++#OPENRE_JSMPEG_PORT=9736
++# Public listener address (default 0.0.0.0).
++#OPENRE_JSMPEG_BIND=0.0.0.0
++# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).
++#OPENRE_JSMPEG_PUBLIC_HOST=ingest.openre.stream
++#OPENRE_JSMPEG_PUBLIC_PORT=9736
++# Loopback ports a worker generation serves its raw MPEG-TS data tap from (the restream worker
++# reads it); first free port in the range, so two generations can run during a drain.
++#OPENRE_JSMPEG_INTERNAL_PORT_MIN=19710
++#OPENRE_JSMPEG_INTERNAL_PORT_MAX=19749
++#OPENRE_JSMPEG_MAX_PUBLISHERS=64
++
++# ── Ingest (WebRTC: WHIP + mediasoup SFU + viewers, openre-webrtc@) ──
++# Public port: WHIP (POST/PATCH/DELETE /whip/…) and the /b/<key>, /w/<id> signaling WebSockets.
++# Live's own WHIP lives on its app port until the WebRTC cutover.
++#OPENRE_WEBRTC_PORT=9936
++# Public listener address (default 0.0.0.0).
++#OPENRE_WEBRTC_BIND=0.0.0.0
++# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).
++#OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream
++#OPENRE_WEBRTC_PUBLIC_PORT=9936
++# Loopback ports a generation serves RTP descriptors from (restream worker, Media's RTP recorder
++# and the thumbnail grabber); first free port in the range.
++#OPENRE_WEBRTC_INTERNAL_PORT_MIN=19810
++#OPENRE_WEBRTC_INTERNAL_PORT_MAX=19849
++#OPENRE_WEBRTC_MAX_PUBLISHERS=64
++# STUN/TURN servers handed to browsers, comma-separated.
++#OPENRE_WEBRTC_STUN=stun:stun.l.google.com:19302
++# Live thumbnails: a frame grabbed from the worker's own source every interval, uploaded to Media
++# (OPENRE_THUMBNAIL_NS, grant media.object.upload ns live) and carried on the session.
++#OPENRE_THUMBNAILS=on
++#OPENRE_THUMBNAIL_INTERVAL_MS=30000
++#OPENRE_THUMBNAIL_WIDTH=640
++#OPENRE_THUMBNAIL_NS=live
++
++# ── mediasoup (inside openre-webrtc) ──────────────────────────
++# Public address RTC candidates are advertised with. REQUIRED in production: without it remote
++# WebRTC clients cannot reach the worker's transports.
++#MEDIASOUP_ANNOUNCED_IP=
++# Interface RTC transports bind to (either name; MEDIASOUP_LISTEN_IP wins).
++#MEDIASOUP_LISTEN_IP=0.0.0.0
++#OPENRE_MEDIASOUP_LISTEN_IP=0.0.0.0
++# Announced address under the OPENRE_ prefix (MEDIASOUP_ANNOUNCED_IP wins when both are set).
++#OPENRE_MEDIASOUP_ANNOUNCED_IP=
++# UDP port range for RTC (never Live's 10000–10100) and how many mediasoup workers per process.
++#OPENRE_MEDIASOUP_MIN_PORT=10200
++#OPENRE_MEDIASOUP_MAX_PORT=10300
++#OPENRE_MEDIASOUP_WORKERS=1
++
# ── Workers, leases, drain ────────────────────────────────────
#OPENRE_WORKER_HEARTBEAT_MS=2000
# A worker silent this long is lost: its sessions fail and its outputs move to another worker.
ddiff --git a/README.md b/README.md
iindex 67fc9e9..9b6a106 100644
---- a/README.md
++++ b/README.md
@@@ -250,7 +250,7 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
### WebRTC (worker ready)
--`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; new ICE credentials restart ICE: `200`, the server's new credentials and a new `ETag`; `If-Match` is checked) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.
++`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; a PATCH must carry `If-Match` — `428` without one, `412` when it names a stale ICE session; new ICE credentials restart ICE: `200`, the server's new credentials, exactly the ICE attributes the fragment carried (`ice-options`, `ice-pacing`, `ice-lite`, `end-of-candidates`, §4.3.3) and a new `ETag`) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one, and a `401` carries `WWW-Authenticate: Bearer`), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.
## Acceptance
ddiff --git a/test/webrtc-e2e.test.js b/test/webrtc-e2e.test.js
iindex e896605..eb789e3 100644
---- a/test/webrtc-e2e.test.js
++++ b/test/webrtc-e2e.test.js
@@@ -16,6 +16,7 @@ const dgram = require('dgram');
const http = require('http');
const WebSocket = require('ws');
const { createWebrtc } = require('../workers/webrtc');
++const sdpTool = require('../workers/webrtc/sdp');
const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');
let werift;
@@@ -168,7 +169,7 @@ t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives it
udp.handle = desc.handle;
await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });
-- const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });
++ const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag', 'if-match': pub.etag }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });
assert.strictEqual(patch.status, 204, 'a candidate of an unknown ICE generation is silently discarded');
});
@@@ -181,10 +182,21 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
assert.strictEqual((await httpReq(port, 'DELETE', unknown)).status, 404, 'DELETE of an unknown resource is 404');
assert.strictEqual((await httpReq(port, 'GET', at)).status, 405);
assert.strictEqual((await httpReq(port, 'PATCH', at, { headers: { 'content-type': 'application/sdp' }, body: 'a=end-of-candidates\r\n' })).status, 415);
++ // RFC 9725 §4.3.1: a PATCH names the ICE session it conditions on, so a missing If-Match is 428
++ // — for a trickle fragment and for an ICE restart alike, before either touches the transport.
++ const noPrecondition = await patch({}, 'a=end-of-candidates\r\n');
++ assert.strictEqual(noPrecondition.status, 428, `PATCH without If-Match (${noPrecondition.status}): ${noPrecondition.body}`);
++ assert.strictEqual(JSON.parse(noPrecondition.body).error_code, 'precondition_required');
++ const noPreconditionRestart = await patch({}, 'a=ice-ufrag:zzzz\r\na=ice-pwd:zzzzzzzzzzzzzzzzzzzzzzzz\r\na=end-of-candidates\r\n');
++ assert.strictEqual(noPreconditionRestart.status, 428, 'an ICE restart without If-Match is 428');
assert.strictEqual((await patch({ 'if-match': '"stale"' }, 'a=end-of-candidates\r\n')).status, 412);
-- assert.strictEqual((await patch({}, 'not an sdp fragment')).status, 400);
-- assert.strictEqual((await patch({ authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n')).status, 401, 'a Bearer that is not the key is refused');
-- assert.strictEqual((await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } })).status, 401);
++ assert.strictEqual((await patch({ 'if-match': pub.etag }, 'not an sdp fragment')).status, 400);
++ const wrongBearer = await patch({ 'if-match': pub.etag, authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n');
++ assert.strictEqual(wrongBearer.status, 401, 'a Bearer that is not the key is refused');
++ assert.strictEqual(wrongBearer.headers['www-authenticate'], 'Bearer', 'a 401 names the Bearer scheme (RFC 9725 §4.5)');
++ const deleteUnauthorized = await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } });
++ assert.strictEqual(deleteUnauthorized.status, 401);
++ assert.strictEqual(deleteUnauthorized.headers['www-authenticate'], 'Bearer', 'DELETE 401 too');
// ICE restart (RFC 9725 §4.3.2): new client credentials → new server credentials and a new ETag.
const before = udp.packets;
@@@ -193,13 +205,26 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
await pub.pc.setLocalDescription(offer);
const ufrag = /a=ice-ufrag:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
const pwd = /a=ice-pwd:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
-- const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\n`);
++ // The fragment carries every attribute the answer must mirror (§4.3.3). A browser only sends
++ // ice-options:trickle; ice-lite is here to prove the server echoes what it got, not a constant.
++ const restartBody = `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\na=ice-options:trickle\r\na=ice-pacing:50\r\na=ice-lite\r\na=end-of-candidates\r\n`;
++ const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, restartBody);
assert.strictEqual(restart.status, 200, `ICE restart (${restart.status}): ${restart.body}`);
assert.match(restart.headers['content-type'], /^application\/trickle-ice-sdpfrag/);
assert.notStrictEqual(restart.headers.etag, pub.etag, 'a restart starts a new ICE session');
-- const serverUfrag = /a=ice-ufrag:(\S+)/.exec(restart.body)[1];
-- const serverPwd = /a=ice-pwd:(\S+)/.exec(restart.body)[1];
-- assert.notStrictEqual(serverUfrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');
++ const request = sdpTool.parseIceFragment(restartBody);
++ const server = sdpTool.parseIceFragment(restart.body);
++ assert.ok(server.ufrag, 'the answer carries the server ufrag');
++ assert.ok(server.pwd, 'the answer carries the server pwd');
++ assert.notStrictEqual(server.ufrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');
++ assert.deepStrictEqual(
++ { iceOptions: server.iceOptions, icePacing: server.icePacing, iceLite: server.iceLite, endOfCandidates: server.endOfCandidates },
++ { iceOptions: request.iceOptions, icePacing: request.icePacing, iceLite: request.iceLite, endOfCandidates: request.endOfCandidates },
++ 'the answer mirrors the request fragment\'s ICE attributes exactly',
++ );
++ assert.strictEqual(server.candidates, 0, 'a restart answer carries no candidates');
++ const serverUfrag = server.ufrag;
++ const serverPwd = server.pwd;
assert.strictEqual((await patch({ 'if-match': pub.etag }, 'a=end-of-candidates\r\n')).status, 412, 'the old ICE session\'s ETag no longer matches');
assert.strictEqual((await patch({ 'if-match': restart.headers.etag }, 'a=end-of-candidates\r\n')).status, 204);
pub.etag = restart.headers.etag;
ddiff --git a/test/webrtc.test.js b/test/webrtc.test.js
iindex 18825e6..44e36a8 100644
---- a/test/webrtc.test.js
++++ b/test/webrtc.test.js
@@@ -67,20 +67,38 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
});
t('WHIP PATCH sdpfrag: credentials, candidates, end-of-candidates; malformed bodies throw', () => {
-- const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'
++ const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\na=ice-options:trickle\r\na=ice-pacing:50\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'
+ 'a=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\n'
+ 'a=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host\r\na=end-of-candidates\r\n');
-- assert.deepStrictEqual(trickle, { ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true });
++ assert.deepStrictEqual(trickle, {
++ ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true,
++ iceOptions: 'trickle', icePacing: '50', iceLite: false,
++ });
// Media-level credentials count too; a bare candidate list has none.
assert.strictEqual(sdp.parseIceFragment('m=video 9 UDP/TLS/RTP/SAVPF 96\na=ice-ufrag:abc\n').ufrag, 'abc');
-- assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), { ufrag: null, pwd: null, candidates: 1, endOfCandidates: false });
++ assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), {
++ ufrag: null, pwd: null, candidates: 1, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,
++ });
assert.strictEqual(sdp.parseIceFragment('').candidates, 0);
++ assert.strictEqual(sdp.parseIceFragment('a=ice-lite\r\n').iceLite, true);
for (const bad of ['hello', '{"candidate":"x"}', 'a=candidate:garbage', undefined]) {
assert.throws(() => sdp.parseIceFragment(bad), (err) => err.code === 'invalid_sdpfrag', `rejects ${bad}`);
}
++
++ // The restart answer is the server's new credentials plus exactly the attributes the request
++ // fragment carried (RFC 9725 §4.3.3): nothing is assumed, ice-lite is not hard-coded.
const frag = sdp.buildIceFragment({ usernameFragment: 'u1', password: 'p1' });
-- assert.deepStrictEqual(sdp.parseIceFragment(frag), { ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false });
-- assert.match(frag, /^a=ice-lite\r\n/, 'the restart answer says the server is ICE-lite');
++ assert.deepStrictEqual(sdp.parseIceFragment(frag), {
++ ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,
++ });
++ assert.ok(!/ice-lite/.test(frag), 'the server does not claim ice-lite the client never sent');
++
++ const mirrored = sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u2', password: 'p2' }, trickle));
++ assert.deepStrictEqual(mirrored, { ...trickle, ufrag: 'u2', pwd: 'p2', candidates: 0 });
++ assert.deepStrictEqual(
++ sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u3', password: 'p3' }, sdp.parseIceFragment('a=ice-lite\r\na=ice-options:trickle\r\n'))),
++ { ufrag: 'u3', pwd: 'p3', candidates: 0, endOfCandidates: false, iceOptions: 'trickle', icePacing: null, iceLite: true },
++ );
});
t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {
ddiff --git a/workers/webrtc.js b/workers/webrtc.js
iindex ee3c8fd..781741e 100644
---- a/workers/webrtc.js
++++ b/workers/webrtc.js
@@@ -47,7 +47,7 @@ const WHIP_CORS = Object.freeze({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
-- 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag',
++ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag, WWW-Authenticate',
'Access-Control-Max-Age': '86400',
'Cross-Origin-Resource-Policy': 'cross-origin',
});
@@@ -200,7 +200,10 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
};
function sendWhipError(res, status, code, message) {
-- res.writeHead(status, { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code });
++ const headers = { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code };
++ // RFC 9725 §4.5: a 401 tells the client how to authenticate (the same scheme the endpoint takes).
++ if (status === 401) headers['WWW-Authenticate'] = 'Bearer';
++ res.writeHead(status, headers);
res.end(JSON.stringify({ error: message, error_code: code }));
}
@@@ -326,7 +329,8 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
* trickled candidates are acknowledged and discarded: the client's connectivity checks reach
* the transport's own candidates (sent in the answer) and mediasoup learns the client's address
* from them. New ICE credentials in the fragment are an ICE restart: the transport gets new
-- * credentials, answered 200 with them and a new ETag; the session stays up either way.
++ * credentials, answered 200 with them (and the ICE attributes the client's fragment carried) and
++ * a new ETag; the session stays up either way. Every PATCH must carry If-Match (428 without one).
*/
async function handleWhipPatch(req, res, resourceId) {
const found = findWhip(resourceId);
@@@ -336,8 +340,12 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
if (!/^application\/trickle-ice-sdpfrag\s*(;|$)/i.test(req.headers['content-type'] || '')) {
return sendWhipError(res, 415, 'unsupported_media_type', 'PATCH body must be application/trickle-ice-sdpfrag');
}
++ // RFC 9725 §4.3.1: a PATCH on a WHIP resource MUST carry If-Match, so a missing one is 428
++ // (not 412: the client never named an ICE session to condition on). '*' and the current
++ // ETag both name the one in force.
const ifMatch = (req.headers['if-match'] || '').trim();
-- if (ifMatch && ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');
++ if (!ifMatch) return sendWhipError(res, 428, 'precondition_required', 'If-Match is required on a WHIP resource (RFC 9725 §4.3.1)');
++ if (ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');
let body;
try { body = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Fragment too large'); }
let frag;
@@@ -354,7 +362,7 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
entry.etag = newIceEtag();
log.log(`[webrtc] WHIP ICE restart for ${sess.sessionId}`);
res.writeHead(200, { ...WHIP_CORS, 'content-type': 'application/trickle-ice-sdpfrag', ETag: entry.etag });
-- return res.end(sdpTool.buildIceFragment(iceParameters));
++ return res.end(sdpTool.buildIceFragment(iceParameters, frag));
}
res.writeHead(204, WHIP_CORS);
res.end();
ddiff --git a/workers/webrtc/sdp.js b/workers/webrtc/sdp.js
iindex 57180be..cfdb83e 100644
---- a/workers/webrtc/sdp.js
++++ b/workers/webrtc/sdp.js
@@@ -266,12 +266,14 @@ const CANDIDATE_RE = /^candidate:\S+ \d+ \S+ \d+ \S+ \d+ typ \S+/i;
/**
* Read a WHIP PATCH body, an RFC 8840 "application/trickle-ice-sdpfrag": the ICE credentials it
-- * names (session or media level, the first wins) and how many candidates it carries. Throws
-- * (code 'invalid_sdpfrag') on anything that is not an SDP fragment or on a malformed candidate.
++ * names (session or media level, the first wins), how many candidates it carries, and the ICE
++ * attributes the answer fragment is expected to mirror (RFC 9725 §4.3.3: ice-options, ice-pacing,
++ * ice-lite, end-of-candidates). Throws (code 'invalid_sdpfrag') on anything that is not an SDP
++ * fragment or on a malformed candidate.
*/
function parseIceFragment(text) {
if (typeof text !== 'string') throw Object.assign(new Error('missing sdpfrag'), { code: 'invalid_sdpfrag' });
-- const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false };
++ const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false };
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
@@@ -281,6 +283,9 @@ function parseIceFragment(text) {
if (attr.startsWith('ice-ufrag:')) out.ufrag = out.ufrag || attr.slice(10).trim();
else if (attr.startsWith('ice-pwd:')) out.pwd = out.pwd || attr.slice(8).trim();
else if (attr === 'end-of-candidates') out.endOfCandidates = true;
++ else if (attr === 'ice-lite') out.iceLite = true;
++ else if (attr.startsWith('ice-options:')) out.iceOptions = out.iceOptions == null ? attr.slice(12).trim() : out.iceOptions;
++ else if (attr.startsWith('ice-pacing:')) out.icePacing = out.icePacing == null ? attr.slice(11).trim() : out.icePacing;
else if (attr.startsWith('candidate:')) {
if (!CANDIDATE_RE.test(attr)) throw Object.assign(new Error('malformed candidate'), { code: 'invalid_sdpfrag' });
out.candidates++;
@@@ -289,9 +294,20 @@ function parseIceFragment(text) {
return out;
}
--/** The sdpfrag a WHIP resource answers an ICE restart with: the media server's new credentials. */
--function buildIceFragment(iceParameters) {
-- return ['a=ice-lite', `a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`, ''].join('\r\n');
++/**
++ * The sdpfrag a WHIP resource answers an ICE restart with (RFC 9725 §4.3.3): the media server's new
++ * credentials, plus exactly the ice-options / ice-pacing / ice-lite / end-of-candidates attributes
++ * the request fragment carried — the server repeats what the client's session declared, it does not
++ * assume any of them (`mirror` is a parsed request fragment; nothing is mirrored by default).
++ */
++function buildIceFragment(iceParameters, mirror = {}) {
++ const lines = [`a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`];
++ if (mirror.iceOptions) lines.push(`a=ice-options:${mirror.iceOptions}`);
++ if (mirror.icePacing) lines.push(`a=ice-pacing:${mirror.icePacing}`);
++ if (mirror.iceLite) lines.push('a=ice-lite');
++ if (mirror.endOfCandidates) lines.push('a=end-of-candidates');
++ lines.push('');
++ return lines.join('\r\n');
}
/**