openvibers/openvibe.live Commits

Let WebKit connect to live streams with TURN transport URLs (#825)

OP OpenVibers committed 2 days ago · 4ee859e606 · 2 files, +78 −3

Changes

ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex f23b007b..f71e4f2b 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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 start
ddiff --git a/test/turn-browser-compat.test.js b/test/turn-browser-compat.test.js
nnew file mode 100644
iindex 00000000..e2be9bb4
---- /dev/null
++++ b/test/turn-browser-compat.test.js
@@@ -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; });