Add optional WebTransport frame transport

This commit is contained in:
2026-06-24 21:28:15 -07:00
parent d37694e4d3
commit 3e2ca8057b
9 changed files with 829 additions and 31 deletions

View File

@@ -70,6 +70,13 @@ const PLAYBACK_RESTART_DELAY_MS = 750;
const MIN_RECOVERY_RESUME_SECONDS = 2;
const METADATA_REFRESH_ATTEMPTS = 20;
const METADATA_REFRESH_INTERVAL_MS = 650;
const WT_STREAM_CONTROL_TO_CLIENT = 1;
const WT_STREAM_FRAME = 2;
const WT_STREAM_CONTROL_TO_SERVER = 3;
const FRAME_CONNECTION_OPEN = 1;
const FRAME_CONNECTION_CLOSING = 2;
const FRAME_CONNECTION_CLOSED = 3;
const textEncoder = new TextEncoder();
const state = {
generation: 0,
@@ -508,10 +515,26 @@ function connectPlaybackStreams() {
state.websocket = null;
}
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}?g=${session.seekGeneration ?? 0}`;
const websocket = new WebSocket(websocketUrl);
websocket.binaryType = 'arraybuffer';
void openPlaybackFrameConnection(session, streamGeneration);
}
async function openPlaybackFrameConnection(session, streamGeneration) {
let websocket;
try {
websocket = await createFrameConnection(session);
} catch (error) {
console.warn('Frame transport failed', error);
showPlayerMessage('Stream failed');
setControlsVisible(true);
return;
}
if (streamGeneration !== state.streamGeneration || state.session?.id !== session.id) {
websocket.close(1000, 'stale frame connection');
return;
}
state.websocket = websocket;
websocket.addEventListener('message', (event) => {
@@ -548,6 +571,249 @@ function connectPlaybackStreams() {
startRenderLoop();
}
async function createFrameConnection(session) {
const preferred = session.frameTransport?.preferred ?? 'websocket';
const webTransportConfig = session.frameTransport?.webTransport;
const shouldTryWebTransport = (preferred === 'webtransport' || preferred === 'auto') && webTransportConfig && 'WebTransport' in window;
if (shouldTryWebTransport) {
try {
return await createWebTransportFrameConnection(session, webTransportConfig);
} catch (error) {
console.warn('WebTransport frame connection failed; falling back to WebSocket', error);
}
}
return createWebSocketFrameConnection(session);
}
function createWebSocketFrameConnection(session) {
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}?g=${session.seekGeneration ?? 0}`;
const websocket = new WebSocket(websocketUrl);
websocket.binaryType = 'arraybuffer';
return websocket;
}
async function createWebTransportFrameConnection(session, config) {
const url = buildWebTransportFrameUrl(session, config);
const options = {
allowPooling: false,
congestionControl: 'low-latency',
};
if (config.certificateHash) {
options.serverCertificateHashes = [{
algorithm: config.certificateHashAlgorithm ?? 'sha-256',
value: base64ToArrayBuffer(config.certificateHash),
}];
}
const transport = new WebTransport(url, options);
await transport.ready;
return createWebTransportFrameConnectionAdapter(transport);
}
function createWebTransportFrameConnectionAdapter(transport) {
const events = new EventTarget();
let readyState = FRAME_CONNECTION_OPEN;
let clientControlWriterPromise = null;
let clientControlWrite = Promise.resolve();
const connection = {
get readyState() {
return readyState;
},
addEventListener(type, listener, options) {
events.addEventListener(type, listener, options);
},
removeEventListener(type, listener, options) {
events.removeEventListener(type, listener, options);
},
send(data) {
if (readyState !== FRAME_CONNECTION_OPEN) {
throw new Error('Frame connection is closed.');
}
clientControlWriterPromise ??= openWebTransportClientControlWriter(transport);
const payload = textEncoder.encode(`${String(data)}\n`);
clientControlWrite = clientControlWrite.then(async () => {
const writer = await clientControlWriterPromise;
await writer.write(payload);
}).catch((error) => {
dispatchFrameConnectionError(events, error);
});
},
close(code = 1000, reason = '') {
if (readyState === FRAME_CONNECTION_CLOSING || readyState === FRAME_CONNECTION_CLOSED) {
return;
}
readyState = FRAME_CONNECTION_CLOSING;
transport.close({ closeCode: code, reason });
},
};
void readWebTransportIncomingStreams(transport, events);
transport.closed.then(() => {
readyState = FRAME_CONNECTION_CLOSED;
events.dispatchEvent(new Event('close'));
}).catch(() => {
readyState = FRAME_CONNECTION_CLOSED;
events.dispatchEvent(new Event('close'));
});
return connection;
}
async function openWebTransportClientControlWriter(transport) {
const stream = await transport.createUnidirectionalStream();
const writer = stream.getWriter();
await writer.write(new Uint8Array([WT_STREAM_CONTROL_TO_SERVER]));
return writer;
}
async function readWebTransportIncomingStreams(transport, events) {
const reader = transport.incomingUnidirectionalStreams.getReader();
try {
while (true) {
const { value: stream, done } = await reader.read();
if (done) {
return;
}
void handleWebTransportIncomingStream(stream, events);
}
} catch (error) {
dispatchFrameConnectionError(events, error);
} finally {
reader.releaseLock();
}
}
async function handleWebTransportIncomingStream(stream, events) {
const reader = stream.getReader();
try {
const first = await reader.read();
if (first.done || !first.value || first.value.byteLength === 0) {
return;
}
const chunk = first.value;
const streamType = chunk[0];
const initialPayload = chunk.subarray(1);
if (streamType === WT_STREAM_CONTROL_TO_CLIENT) {
await readWebTransportControlStream(reader, initialPayload, events);
return;
}
if (streamType === WT_STREAM_FRAME) {
await readWebTransportFrameStream(reader, initialPayload, events);
}
} catch (error) {
dispatchFrameConnectionError(events, error);
} finally {
reader.releaseLock();
}
}
async function readWebTransportControlStream(reader, initialPayload, events) {
const decoder = new TextDecoder();
let pending = initialPayload.byteLength > 0 ? decoder.decode(initialPayload, { stream: true }) : '';
while (true) {
pending = dispatchWebTransportControlLines(pending, events);
const { value, done } = await reader.read();
if (done) {
pending += decoder.decode();
dispatchWebTransportControlLines(`${pending}\n`, events);
return;
}
pending += decoder.decode(value, { stream: true });
}
}
async function readWebTransportFrameStream(reader, initialPayload, events) {
const chunks = [];
let byteLength = 0;
if (initialPayload.byteLength > 0) {
chunks.push(initialPayload);
byteLength += initialPayload.byteLength;
}
while (true) {
const { value, done } = await reader.read();
if (done) {
const packet = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
packet.set(chunk, offset);
offset += chunk.byteLength;
}
events.dispatchEvent(new MessageEvent('message', { data: packet.buffer }));
return;
}
chunks.push(value);
byteLength += value.byteLength;
}
}
function dispatchWebTransportControlLines(text, events) {
const lines = text.split(/\n/);
const pending = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) {
events.dispatchEvent(new MessageEvent('message', { data: line }));
}
}
return pending;
}
function dispatchFrameConnectionError(events, error) {
if (isExpectedWebTransportCloseError(error)) {
return;
}
console.warn('Frame connection error', error);
events.dispatchEvent(new Event('error'));
}
function isExpectedWebTransportCloseError(error) {
return error && String(error.message ?? error).toLowerCase().includes('session is closed');
}
function buildWebTransportFrameUrl(session, config) {
const host = config.host || window.location.hostname;
const port = config.port || window.location.port;
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return `https://${urlHost}:${port}/frames/${session.id}?g=${session.seekGeneration ?? 0}`;
}
function base64ToArrayBuffer(value) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
async function playAudio() {
try {
await elements.audio.play();