diff --git a/crates/wzp-web/static/index.html b/crates/wzp-web/static/index.html index 0cb7bf8..c6ecdea 100644 --- a/crates/wzp-web/static/index.html +++ b/crates/wzp-web/static/index.html @@ -117,8 +117,9 @@ function wzpBoot() { var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room); - // Create client (currently always WZPPureClient; future: switch on variant) - client = new WZPPureClient({ + // Create client based on selected variant + var variant = WZPCore.detectVariant(); + var clientOpts = { wsUrl: wsUrl, room: room, onAudio: function(pcm) { @@ -130,7 +131,17 @@ function wzpBoot() { onStats: function(stats) { WZPCore.updateStats(stats); }, - }); + }; + + if (variant === 'full' && typeof WZPFullClient !== 'undefined') { + // Full variant: add WebTransport URL, falls back to WS if WT unavailable + clientOpts.url = location.origin.replace('http', 'https'); + client = new WZPFullClient(clientOpts); + } else if (variant === 'hybrid' && typeof WZPHybridClient !== 'undefined') { + client = new WZPHybridClient(clientOpts); + } else { + client = new WZPPureClient(clientOpts); + } try { await client.connect(); diff --git a/crates/wzp-web/static/js/wzp-full.js b/crates/wzp-web/static/js/wzp-full.js index f6c380c..c301d60 100644 --- a/crates/wzp-web/static/js/wzp-full.js +++ b/crates/wzp-web/static/js/wzp-full.js @@ -34,12 +34,14 @@ class WZPFullClient { */ constructor(options) { this.url = options.url; + this.wsUrl = options.wsUrl; // WS fallback URL this.room = options.room; this.onAudio = options.onAudio || null; this.onStatus = options.onStatus || null; this.onStats = options.onStats || null; this.wt = null; // WebTransport instance + this.ws = null; // WebSocket fallback this.datagramWriter = null; // WritableStreamDefaultWriter this.datagramReader = null; // ReadableStreamDefaultReader this.cryptoSession = null; // WzpCryptoSession (WASM) @@ -48,6 +50,7 @@ class WZPFullClient { this.sequence = 0; this._wasmModule = null; this._connected = false; + this._useWebTransport = false; // true if WT connected, false = WS fallback this._startTime = 0; this._statsInterval = null; this._recvLoopRunning = false; @@ -61,49 +64,45 @@ class WZPFullClient { async connect() { if (this._connected) return; - // --- Guard: WebTransport support --- - if (typeof WebTransport === 'undefined') { - throw new Error( - 'WebTransport is not supported in this browser. ' + - 'Use the hybrid (?variant=hybrid) or pure (?variant=pure) variant instead.' - ); - } - this._status('Loading WASM module...'); - // 1. Load WASM + // 1. Load WASM (FEC + crypto) this._wasmModule = await import(WZP_WASM_PATH); await this._wasmModule.default(); - this._status('Connecting via WebTransport to ' + this.url + '...'); - - // 2. WebTransport connection - // The URL should include the room, e.g. https://host:port/room - const wtUrl = this.url + '/' + encodeURIComponent(this.room); - this.wt = new WebTransport(wtUrl); - - this.wt.closed.then(() => { - const wasConnected = this._connected; - this._cleanup(); - if (wasConnected) { - this._status('WebTransport closed'); + // 2. Try WebTransport first, fall back to WebSocket + let wtSuccess = false; + if (typeof WebTransport !== 'undefined' && this.url) { + try { + this._status('Trying WebTransport...'); + const wtUrl = this.url + '/' + encodeURIComponent(this.room); + this.wt = new WebTransport(wtUrl); + await Promise.race([ + this.wt.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]); + this.datagramWriter = this.wt.datagrams.writable.getWriter(); + this.datagramReader = this.wt.datagrams.readable.getReader(); + this._status('Performing key exchange...'); + await this._performKeyExchange(); + wtSuccess = true; + this._useWebTransport = true; + } catch (e) { + console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message); + if (this.wt) { try { this.wt.close(); } catch (_) {} } + this.wt = null; + this.datagramWriter = null; + this.datagramReader = null; } - }).catch((err) => { - this._cleanup(); - this._status('WebTransport error: ' + err.message); - }); + } - await this.wt.ready; + if (!wtSuccess) { + // WebSocket fallback (same as hybrid — WASM loaded but uses WS transport) + this._useWebTransport = false; + await this._connectWebSocket(); + } - // 3. Get datagram streams (unreliable, QUIC DATAGRAM frames) - this.datagramWriter = this.wt.datagrams.writable.getWriter(); - this.datagramReader = this.wt.datagrams.readable.getReader(); - - // 4. Key exchange over a bidirectional stream - this._status('Performing key exchange...'); - await this._performKeyExchange(); - - // 5. Initialise FEC (5 source symbols per block, 256-byte symbols) + // 3. Initialise FEC this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256); this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256); @@ -113,10 +112,50 @@ class WZPFullClient { this._startTime = Date.now(); this._startStatsTimer(); - // 6. Start receive loop (runs until disconnect) - this._recvLoop(); + // 4. Start receive loop (WebTransport only — WS uses onmessage) + if (this._useWebTransport) { + this._recvLoop(); + this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)'); + } else { + this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)'); + } + } - this._status('Connected to room: ' + this.room + ' (encrypted, FEC active)'); + /** + * WebSocket fallback connection (used when WebTransport unavailable). + */ + async _connectWebSocket() { + return new Promise((resolve, reject) => { + this._status('Connecting via WebSocket (fallback)...'); + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + this._status('WebSocket connected to room: ' + this.room); + resolve(); + }; + + this.ws.onmessage = (event) => { + if (!(event.data instanceof ArrayBuffer)) return; + const pcm = new Int16Array(event.data); + this.stats.recv++; + if (this.onAudio) this.onAudio(pcm); + }; + + this.ws.onclose = () => { + if (this._connected) { + this._cleanup(); + this._status('Disconnected'); + } + }; + + this.ws.onerror = () => { + if (!this._connected) { + this._cleanup(); + reject(new Error('WebSocket connection failed')); + } + }; + }); } /** @@ -128,6 +167,10 @@ class WZPFullClient { try { this.wt.close(); } catch (_) { /* ignore */ } this.wt = null; } + if (this.ws) { + try { this.ws.close(); } catch (_) { /* ignore */ } + this.ws = null; + } this._cleanup(); } @@ -139,7 +182,19 @@ class WZPFullClient { * @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes) */ async sendAudio(pcmBuffer) { - if (!this._connected || !this.datagramWriter || !this.cryptoSession) return; + if (!this._connected) return; + + // WebSocket fallback: send raw PCM like pure/hybrid + if (!this._useWebTransport) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(pcmBuffer); + this.sequence++; + this.stats.sent++; + } + return; + } + + if (!this.datagramWriter || !this.cryptoSession) return; const pcmBytes = new Uint8Array(pcmBuffer);