Add optional WebTransport frame transport
This commit is contained in:
274
public/app.js
274
public/app.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user