openvibers/openvibe.live Commits
Let WebKit connect to live streams with TURN transport URLs (#825)
Changes
@@@ -759,6 +759,24 @@ function sanitizeIceServers(iceServers) {return sanitized;}++function createRecvTransportWithIceFallback(device, params, iceServers) {++ try {++ return device.createRecvTransport({ ...params, iceServers });++ } catch (error) {++ // WebKit rejects the valid ?transport=tcp TURN form while Chromium accepts it.++ // Keep the base TURN endpoint and credentials, then retry the same transport.++ if (error?.name !== 'SyntaxError' || !/TURN URL query string/i.test(error.message)) throw error;++ const compatible = iceServers.flatMap((server) => {++ const urls = (Array.isArray(server.urls) ? server.urls : [server.urls])++ .filter((url) => !/^turns?:[^?]+\?/i.test(url));++ return urls.length ? [{ ...server, urls: urls.length === 1 ? urls[0] : urls }] : [];++ });++ if (compatible.length === iceServers.length && compatible.every((server, index) => server.urls === iceServers[index].urls)) throw error;++ console.warn('[Player] Browser rejected TURN transport query; retrying with base ICE endpoints');++ return device.createRecvTransport({ ...params, iceServers: compatible });++ }++}++function isMediaRecorderSupported() {return typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function';}@@@ -1590,13 +1608,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc}console.log('[Player] Using SFU ICE servers:', iceServers.map((s) => (Array.isArray(s.urls) ? s.urls.join(',') : s.urls)).join(' | '));-- recvTransport = device.createRecvTransport({++ recvTransport = createRecvTransportWithIceFallback(device, {id: transportParams.id,iceParameters: transportParams.iceParameters,iceCandidates: transportParams.iceCandidates,dtlsParameters: transportParams.dtlsParameters,-- iceServers,-- });++ }, iceServers);player._sfuRecvTransport = recvTransport;// Wire transport 'connect' event — mediasoup-client fires this when DTLS needs to startnnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++++const source = fs.readFileSync(path.join(__dirname, '../public/js/stream-player.js'), 'utf8');++const start = source.indexOf('function createRecvTransportWithIceFallback(');++const end = source.indexOf('\nfunction isMediaRecorderSupported(', start);++assert.ok(start >= 0 && end > start);++const create = new Function(`${source.slice(start, end)}; return createRecvTransportWithIceFallback;`)();++const iceServers = [++ { urls: 'stun:stun.example.org:3478' },++ { urls: 'turn:turn.example.org:3478', username: 'test', credential: 'test' },++ { urls: 'turn:turn.example.org:3478?transport=tcp', username: 'test', credential: 'test' },++];++++const calls = [];++const device = { createRecvTransport(params) {++ calls.push(params);++ if (params.iceServers.some((s) => String(s.urls).includes('?transport='))) {++ throw new SyntaxError('Invalid TURN URL query string');++ }++ return params;++} };++const result = create(device, { id: 'recv' }, iceServers);++assert.equal(calls.length, 2);++assert.deepEqual(result.iceServers, iceServers.slice(0, 2));++assert.equal(result.iceServers[1].credential, 'test');++assert.throws(() => create({ createRecvTransport() { throw new Error('connection failed'); } }, {}, iceServers), /connection failed/);++++// The engine check proves the exact constructor difference behind this regression.++let playwright;++try { playwright = require(process.env.OV_PLAYWRIGHT || 'playwright'); } catch { /* optional locally */ }++(async () => {++ if (playwright) {++ for (const name of ['chromium', 'webkit']) {++ const browser = await playwright[name].launch({ headless: true });++ try {++ const page = await browser.newPage();++ const value = await page.evaluate((fn) => {++ const helper = new Function(`${fn}; return createRecvTransportWithIceFallback;`)();++ const servers = [++ { urls: 'turn:turn.example.org:3478', username: 'test', credential: 'test' },++ { urls: 'turn:turn.example.org:3478?transport=tcp', username: 'test', credential: 'test' },++ ];++ const device = { createRecvTransport(params) {++ const pc = new RTCPeerConnection({ iceServers: params.iceServers });++ pc.close();++ return params;++ } };++ return helper(device, { id: 'recv' }, servers).iceServers.map((s) => s.urls);++ }, source.slice(start, end));++ assert.equal(value.length, name === 'webkit' ? 1 : 2, `${name} ICE compatibility`);++ } finally { await browser.close(); }++ }++ }++ console.log(`turn-browser-compat: fallback keeps TURN credentials${playwright ? '; Chromium and WebKit verified' : ''}`);++})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -759,6 +759,24 @@ function sanitizeIceServers(iceServers) {
@@ -759,6 +759,24 @@ function sanitizeIceServers(iceServers) {
return sanitized;
return sanitized;
}
}
+function createRecvTransportWithIceFallback(device, params, iceServers) {
+ try {
+ return device.createRecvTransport({ ...params, iceServers });
+ } catch (error) {
+ // WebKit rejects the valid ?transport=tcp TURN form while Chromium accepts it.
+ // Keep the base TURN endpoint and credentials, then retry the same transport.
+ if (error?.name !== 'SyntaxError' || !/TURN URL query string/i.test(error.message)) throw error;
+ const compatible = iceServers.flatMap((server) => {
+ const urls = (Array.isArray(server.urls) ? server.urls : [server.urls])
+ .filter((url) => !/^turns?:[^?]+\?/i.test(url));
+ return urls.length ? [{ ...server, urls: urls.length === 1 ? urls[0] : urls }] : [];
+ });
+ if (compatible.length === iceServers.length && compatible.every((server, index) => server.urls === iceServers[index].urls)) throw error;
+ console.warn('[Player] Browser rejected TURN transport query; retrying with base ICE endpoints');
+ return device.createRecvTransport({ ...params, iceServers: compatible });
+ }
+}
+
function isMediaRecorderSupported() {
function isMediaRecorderSupported() {
return typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function';
return typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function';
}
}
@@ -1590,13 +1608,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1590,13 +1608,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
}
}
console.log('[Player] Using SFU ICE servers:', iceServers.map((s) => (Array.isArray(s.urls) ? s.urls.join(',') : s.urls)).join(' | '));
console.log('[Player] Using SFU ICE servers:', iceServers.map((s) => (Array.isArray(s.urls) ? s.urls.join(',') : s.urls)).join(' | '));
- recvTransport = device.createRecvTransport({
+ recvTransport = createRecvTransportWithIceFallback(device, {
id: transportParams.id,
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
dtlsParameters: transportParams.dtlsParameters,
- iceServers,
- });
+ }, iceServers);
player._sfuRecvTransport = recvTransport;
player._sfuRecvTransport = recvTransport;
// Wire transport 'connect' event — mediasoup-client fires this when DTLS needs to start
// Wire transport 'connect' event — mediasoup-client fires this when DTLS needs to start
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const source = fs.readFileSync(path.join(__dirname, '../public/js/stream-player.js'), 'utf8');
+const start = source.indexOf('function createRecvTransportWithIceFallback(');
+const end = source.indexOf('\nfunction isMediaRecorderSupported(', start);
+assert.ok(start >= 0 && end > start);
+const create = new Function(`${source.slice(start, end)}; return createRecvTransportWithIceFallback;`)();
+const iceServers = [
+ { urls: 'stun:stun.example.org:3478' },
+ { urls: 'turn:turn.example.org:3478', username: 'test', credential: 'test' },
+ { urls: 'turn:turn.example.org:3478?transport=tcp', username: 'test', credential: 'test' },
+];
+
+const calls = [];
+const device = { createRecvTransport(params) {
+ calls.push(params);
+ if (params.iceServers.some((s) => String(s.urls).includes('?transport='))) {
+ throw new SyntaxError('Invalid TURN URL query string');
+ }
+ return params;
+} };
+const result = create(device, { id: 'recv' }, iceServers);
+assert.equal(calls.length, 2);
+assert.deepEqual(result.iceServers, iceServers.slice(0, 2));
+assert.equal(result.iceServers[1].credential, 'test');
+assert.throws(() => create({ createRecvTransport() { throw new Error('connection failed'); } }, {}, iceServers), /connection failed/);
+
+// The engine check proves the exact constructor difference behind this regression.
+let playwright;
+try { playwright = require(process.env.OV_PLAYWRIGHT || 'playwright'); } catch { /* optional locally */ }
+(async () => {
+ if (playwright) {
+ for (const name of ['chromium', 'webkit']) {
+ const browser = await playwright[name].launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ const value = await page.evaluate((fn) => {
+ const helper = new Function(`${fn}; return createRecvTransportWithIceFallback;`)();
+ const servers = [
+ { urls: 'turn:turn.example.org:3478', username: 'test', credential: 'test' },
+ { urls: 'turn:turn.example.org:3478?transport=tcp', username: 'test', credential: 'test' },
+ ];
+ const device = { createRecvTransport(params) {
+ const pc = new RTCPeerConnection({ iceServers: params.iceServers });
+ pc.close();
+ return params;
+ } };
+ return helper(device, { id: 'recv' }, servers).iceServers.map((s) => s.urls);
+ }, source.slice(start, end));
+ assert.equal(value.length, name === 'webkit' ? 1 : 2, `${name} ICE compatibility`);
+ } finally { await browser.close(); }
+ }
+ }
+ console.log(`turn-browser-compat: fallback keeps TURN credentials${playwright ? '; Chromium and WebKit verified' : ''}`);
+})().catch((error) => { console.error(error); process.exitCode = 1; });