fix: query params for websocket

Hee Sung Son committed Nov 12, 2025 at 13:47 UTC 4c5ed619687942f8ec4bfa242d4e898ed27c76d6
1 file changed +184 -116
cmd/webclient/polyfill.js
+184 -116
@@ -7,6 +7,17 @@
7 // Save original WebSocket
8 const NativeWebSocket = window.WebSocket;
9
10 + // Conditional logging helper - only log when localhost is in URL
11 + const isLocalhost = window.location.hostname === 'localhost' ||
12 + window.location.hostname === '127.0.0.1' ||
13 + window.location.hostname.endsWith('.localhost');
14 +
15 + function debugLog(...args) {
16 + if (isLocalhost) {
17 + console.log(...args);
18 + }
19 + }
20 +
21 // Generate unique client ID
22 function generateClientId() {
23 return `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -19,37 +30,135 @@
30 return btoa(String.fromCharCode(...bytes));
31 }
32
22 - // WebSocket polyfill using Service Worker E2EE
23 - class WebSocketPolyfill {
33 + // Helper to validate WebSocket constructor arguments (mimics native behavior)
34 + function validateWebSocketArgs(url, protocols) {
35 + if (!url) {
36 + throw new DOMException("Failed to construct 'WebSocket': 1 argument required, but only 0 present.");
37 + }
38 +
39 + let parsedUrl;
40 + try {
41 + parsedUrl = new URL(url, window.location.href);
42 + } catch (e) {
43 + throw new DOMException(`Failed to construct 'WebSocket': The URL '${url}' is invalid.`);
44 + }
45 +
46 + if (parsedUrl.protocol !== 'ws:' && parsedUrl.protocol !== 'wss:') {
47 + throw new DOMException(`Failed to construct 'WebSocket': The URL's scheme must be either 'ws' or 'wss'. '${parsedUrl.protocol.slice(0, -1)}' is not allowed.`);
48 + }
49 +
50 + let normalizedProtocols = protocols;
51 + if (protocols !== undefined && protocols !== null) {
52 + if (typeof protocols === 'string') {
53 + normalizedProtocols = [protocols];
54 + } else if (Array.isArray(protocols)) {
55 + normalizedProtocols = protocols;
56 + } else {
57 + throw new DOMException("Failed to construct 'WebSocket': The subprotocol '" + protocols + "' is invalid.");
58 + }
59 +
60 + const seen = new Set();
61 + for (const protocol of normalizedProtocols) {
62 + if (typeof protocol !== 'string') {
63 + throw new DOMException("Failed to construct 'WebSocket': The subprotocol '" + protocol + "' is invalid.");
64 + }
65 + if (protocol === '') {
66 + throw new DOMException("Failed to construct 'WebSocket': The subprotocol '' is invalid.");
67 + }
68 + if (seen.has(protocol)) {
69 + throw new DOMException(`Failed to construct 'WebSocket': The subprotocol '${protocol}' is duplicated.`);
70 + }
71 + seen.add(protocol);
72 + }
73 + }
74 +
75 + return { parsedUrl, normalizedProtocols };
76 + }
77 +
78 + // WebSocket polyfill using Service Worker E2EE - extends EventTarget for native event handling
79 + class WebSocketPolyfill extends EventTarget {
80 + // WebSocket ready state constants (matching native WebSocket)
81 + static CONNECTING = 0;
82 + static OPEN = 1;
83 + static CLOSING = 2;
84 + static CLOSED = 3;
85 +
86 constructor(url, protocols) {
25 - this.url = url;
26 - this.protocols = protocols;
27 - this.readyState = WebSocket.CONNECTING;
28 - this.bufferedAmount = 0;
29 - this.extensions = "";
30 - this.protocol = "";
87 + super(); // Initialize EventTarget
88 +
89 + // Validate arguments using native-like validation
90 + const { parsedUrl, normalizedProtocols } = validateWebSocketArgs(url, protocols);
91 +
92 + // Store original URL and protocols
93 + this._url = parsedUrl.href;
94 + this._protocols = normalizedProtocols;
95 + this._parsedUrl = parsedUrl;
96 +
97 + // Define read-only properties to match native WebSocket
98 + Object.defineProperty(this, 'url', {
99 + get: () => this._url,
100 + enumerable: true,
101 + configurable: true
102 + });
103 +
104 + Object.defineProperty(this, 'readyState', {
105 + get: () => this._readyState,
106 + enumerable: true,
107 + configurable: true
108 + });
109 +
110 + Object.defineProperty(this, 'bufferedAmount', {
111 + get: () => this._bufferedAmount,
112 + enumerable: true,
113 + configurable: true
114 + });
115 +
116 + Object.defineProperty(this, 'extensions', {
117 + get: () => this._extensions,
118 + enumerable: true,
119 + configurable: true
120 + });
121 +
122 + Object.defineProperty(this, 'protocol', {
123 + get: () => this._protocol,
124 + enumerable: true,
125 + configurable: true
126 + });
127 +
128 + // Internal state
129 + this._readyState = WebSocketPolyfill.CONNECTING;
130 + this._bufferedAmount = 0;
131 + this._extensions = "";
132 + this._protocol = "";
133 this.binaryType = "blob";
134
33 - // Event handlers
135 + // Event handlers (use native-like pattern)
136 this.onopen = null;
137 this.onmessage = null;
138 this.onerror = null;
139 this.onclose = null;
140
39 - // Internal state
141 + // Internal connection state
142 this._clientId = generateClientId();
143 this._connId = null;
144 this._isClosed = false;
145 this._wsKey = generateWebSocketKey();
146 this._frameBuffer = new Uint8Array(0);
147
46 - // Setup Service Worker message listener
148 + // Setup and connect
149 this._setupMessageListener();
48 -
49 - // Initialize connection
150 this._connect();
151 }
152
153 + // Helper to send messages to Service Worker
154 + _postToServiceWorker(message) {
155 + navigator.serviceWorker.controller.postMessage({
156 + clientId: this._clientId,
157 + connId: this._connId,
158 + ...message
159 + });
160 + }
161 +
162 _setupMessageListener() {
163 navigator.serviceWorker.addEventListener("message", (event) => {
164 const data = event.data;
@@ -80,11 +189,10 @@
189 }
190
191 async _connect() {
83 - console.log("[WebSocket Polyfill] Connecting via Service Worker SDK to:", this.url);
192 + debugLog("[WebSocket Polyfill] Connecting via Service Worker SDK to:", this._url);
193 try {
85 - // Extract hostname from URL
86 - const urlObj = new URL(this.url);
87 - let hostname = urlObj.hostname;
194 + // Extract and normalize hostname (already parsed in constructor)
195 + let hostname = this._parsedUrl.hostname;
196
197 // Normalize punycode to lowercase (punycode is case-insensitive per RFC 3492)
198 // This ensures XN--CW4B85OB9G becomes xn--cw4b85ob9g before processing
@@ -94,12 +202,12 @@
202 // Go backend will convert punycode->unicode and then uppercase
203 const leaseName = hostname.split('.')[0];
204
97 - console.log("[WebSocket Polyfill] Lease name:", leaseName);
205 + debugLog("[WebSocket Polyfill] Lease name:", leaseName);
206
207 // Wait for Service Worker to be ready
208 await navigator.serviceWorker.ready;
209
102 - // Send connect message to Service Worker
210 + // Send connect message to Service Worker (note: connId not set yet, so we manually include clientId)
211 navigator.serviceWorker.controller.postMessage({
212 type: "SDK_CONNECT",
213 clientId: this._clientId,
@@ -115,17 +223,16 @@
223 _handleConnectSuccess(data) {
224 this._connId = data.connId;
225
118 - console.log("[WebSocket Polyfill] E2EE connection established, sending WebSocket upgrade");
226 + debugLog("[WebSocket Polyfill] E2EE connection established, sending WebSocket upgrade");
227
228 // Send WebSocket HTTP Upgrade request
229 this._sendWebSocketUpgrade();
230 }
231
232 _sendWebSocketUpgrade() {
125 - // Parse URL to get path
126 - const urlObj = new URL(this.url);
127 - const path = urlObj.pathname || "/";
128 - const host = urlObj.host;
233 + // Parse URL to get path with query parameters
234 + const path = (this._parsedUrl.pathname || "/") + (this._parsedUrl.search || "");
235 + const host = this._parsedUrl.host;
236
237 // Build HTTP Upgrade request
238 let upgradeRequest = `GET ${path} HTTP/1.1\r\n`;
@@ -135,25 +242,23 @@
242 upgradeRequest += `Sec-WebSocket-Key: ${this._wsKey}\r\n`;
243 upgradeRequest += `Sec-WebSocket-Version: 13\r\n`;
244
138 - if (this.protocols) {
139 - const protocolStr = Array.isArray(this.protocols)
140 - ? this.protocols.join(', ')
141 - : this.protocols;
245 + if (this._protocols) {
246 + const protocolStr = Array.isArray(this._protocols)
247 + ? this._protocols.join(', ')
248 + : this._protocols;
249 upgradeRequest += `Sec-WebSocket-Protocol: ${protocolStr}\r\n`;
250 }
251
252 upgradeRequest += `\r\n`;
253
147 - console.log("[WebSocket Polyfill] Sending upgrade request:", upgradeRequest);
254 + debugLog("[WebSocket Polyfill] Sending upgrade request:", upgradeRequest);
255
256 // Convert to bytes and send
257 const encoder = new TextEncoder();
258 const bytes = encoder.encode(upgradeRequest);
259
153 - navigator.serviceWorker.controller.postMessage({
260 + this._postToServiceWorker({
261 type: "SDK_SEND",
155 - clientId: this._clientId,
156 - connId: this._connId,
262 data: bytes,
263 });
264
@@ -191,7 +296,7 @@
296
297 // Parse HTTP response
298 const headers = text.substring(0, headerEndIndex);
194 - console.log("[WebSocket Polyfill] Received upgrade response:", headers);
299 + debugLog("[WebSocket Polyfill] Received upgrade response:", headers);
300
301 // Check if upgrade was successful
302 if (!headers.includes('HTTP/1.1 101') && !headers.includes('HTTP/1.0 101')) {
@@ -202,19 +307,16 @@
307 // Extract protocol if present
308 const protocolMatch = headers.match(/Sec-WebSocket-Protocol:\s*(\S+)/i);
309 if (protocolMatch) {
205 - this.protocol = protocolMatch[1];
310 + this._protocol = protocolMatch[1];
311 }
312
313 // Upgrade successful!
314 this._waitingForUpgrade = false;
210 - this.readyState = WebSocket.OPEN;
315 + this._readyState = WebSocketPolyfill.OPEN;
316
212 - console.log("[WebSocket Polyfill] WebSocket connection established");
317 + debugLog("[WebSocket Polyfill] WebSocket connection established");
318
214 - // Fire onopen event
215 - if (this.onopen) {
216 - this.onopen(new Event("open"));
217 - }
319 + // Fire onopen event (dispatchEvent will handle both onopen and listeners)
320 this.dispatchEvent(new Event("open"));
321
322 // If there's any data after the headers, process it as WebSocket frames
@@ -235,7 +337,7 @@
337 // For now, assume data is the payload (we'll implement frame parsing if needed)
338 // WebSocket frames from server are not masked
339
238 - if (this.readyState !== WebSocket.OPEN) return;
340 + if (this._readyState !== WebSocketPolyfill.OPEN) return;
341
342 // Append incoming data to frame buffer
343 const newBuffer = new Uint8Array(this._frameBuffer.length + data.length);
@@ -288,11 +390,8 @@
390 const text = new TextDecoder().decode(payload);
391 const event = new MessageEvent("message", {
392 data: text,
291 - origin: new URL(this.url).origin,
393 + origin: this._parsedUrl.origin,
394 });
293 - if (this.onmessage) {
294 - this.onmessage(event);
295 - }
395 this.dispatchEvent(event);
396 } else if (opcode === 0x02) {
397 // Binary frame
@@ -304,11 +403,8 @@
403 }
404 const event = new MessageEvent("message", {
405 data: eventData,
307 - origin: new URL(this.url).origin,
406 + origin: this._parsedUrl.origin,
407 });
309 - if (this.onmessage) {
310 - this.onmessage(event);
311 - }
408 this.dispatchEvent(event);
409 } else if (opcode === 0x08) {
410 // Close frame
@@ -333,10 +429,8 @@
429 _sendPong(payload) {
430 // Send pong frame
431 const frame = this._createWebSocketFrame(0x0A, payload);
336 - navigator.serviceWorker.controller.postMessage({
432 + this._postToServiceWorker({
433 type: "SDK_SEND",
338 - clientId: this._clientId,
339 - connId: this._connId,
434 data: frame,
435 });
436 }
@@ -396,7 +490,7 @@
490 const code = data.code || 1000;
491 const reason = data.reason || "";
492
399 - console.log(
493 + debugLog(
494 "[WebSocket Polyfill] Connection closed, code:",
495 code,
496 "reason:",
@@ -404,7 +498,7 @@
498 );
499
500 this._isClosed = true;
407 - this.readyState = WebSocket.CLOSED;
501 + this._readyState = WebSocketPolyfill.CLOSED;
502
503 const event = new CloseEvent("close", {
504 code: code,
@@ -412,9 +506,7 @@
506 wasClean: code === 1000,
507 });
508
415 - if (this.onclose) {
416 - this.onclose(event);
417 - }
509 + // dispatchEvent will handle both onclose and event listeners
510 this.dispatchEvent(event);
511 }
512
@@ -429,9 +521,7 @@
521 const event = new Event("error");
522 event.error = error;
523
432 - if (this.onerror) {
433 - this.onerror(event);
434 - }
524 + // dispatchEvent will handle both onerror and event listeners
525 this.dispatchEvent(event);
526
527 // Close connection after error
@@ -441,8 +531,8 @@
531 }
532
533 send(data) {
444 - if (this.readyState !== WebSocket.OPEN) {
445 - throw new Error("WebSocket is not open");
534 + if (this._readyState !== WebSocketPolyfill.OPEN) {
535 + throw new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.");
536 }
537
538 if (!this._connId) {
@@ -469,10 +559,8 @@
559 data.arrayBuffer().then(arrayBuffer => {
560 const bytes = new Uint8Array(arrayBuffer);
561 const frame = this._createWebSocketFrame(0x02, bytes);
472 - navigator.serviceWorker.controller.postMessage({
562 + this._postToServiceWorker({
563 type: "SDK_SEND",
474 - clientId: this._clientId,
475 - connId: this._connId,
564 data: frame,
565 });
566 });
@@ -485,10 +573,8 @@
573 const frame = this._createWebSocketFrame(opcode, bytes);
574
575 // Send to Service Worker
488 - navigator.serviceWorker.controller.postMessage({
576 + this._postToServiceWorker({
577 type: "SDK_SEND",
490 - clientId: this._clientId,
491 - connId: this._connId,
578 data: frame,
579 });
580 } catch (error) {
@@ -498,20 +584,20 @@
584 }
585
586 close(code = 1000, reason = "") {
501 - if (this._isClosed || this.readyState === WebSocket.CLOSING) {
587 + if (this._isClosed || this._readyState === WebSocketPolyfill.CLOSING) {
588 return;
589 }
590
505 - console.log(
591 + debugLog(
592 "[WebSocket Polyfill] Client initiated close, code:",
593 code,
594 "reason:",
595 reason
596 );
597
512 - this.readyState = WebSocket.CLOSING;
598 + this._readyState = WebSocketPolyfill.CLOSING;
599
514 - if (this._connId && this.readyState === WebSocket.OPEN) {
600 + if (this._connId && this._readyState === WebSocketPolyfill.OPEN) {
601 // Send WebSocket close frame
602 const reasonBytes = new TextEncoder().encode(reason);
603 const payload = new Uint8Array(2 + reasonBytes.length);
@@ -521,20 +607,16 @@
607
608 const frame = this._createWebSocketFrame(0x08, payload);
609
524 - navigator.serviceWorker.controller.postMessage({
610 + this._postToServiceWorker({
611 type: "SDK_SEND",
526 - clientId: this._clientId,
527 - connId: this._connId,
612 data: frame,
613 });
614 }
615
616 // Close SDK connection
617 if (this._connId) {
534 - navigator.serviceWorker.controller.postMessage({
618 + this._postToServiceWorker({
619 type: "SDK_CLOSE",
536 - clientId: this._clientId,
537 - connId: this._connId,
620 });
621 }
622
@@ -542,35 +624,20 @@
624 this._handleDataClose({ code, reason });
625 }
626
545 - // EventTarget implementation
546 - addEventListener(type, listener) {
547 - if (!this._listeners) {
548 - this._listeners = {};
549 - }
550 - if (!this._listeners[type]) {
551 - this._listeners[type] = [];
552 - }
553 - this._listeners[type].push(listener);
554 - }
555 -
556 - removeEventListener(type, listener) {
557 - if (!this._listeners || !this._listeners[type]) {
558 - return;
559 - }
560 - const index = this._listeners[type].indexOf(listener);
561 - if (index !== -1) {
562 - this._listeners[type].splice(index, 1);
563 - }
564 - }
565 -
627 + // Override dispatchEvent to handle onXXX handlers (EventTarget provides addEventListener/removeEventListener)
628 dispatchEvent(event) {
567 - if (!this._listeners || !this._listeners[event.type]) {
568 - return true;
629 + // Call onXXX handler first (matches native WebSocket behavior)
630 + const handlerName = 'on' + event.type;
631 + if (typeof this[handlerName] === 'function') {
632 + try {
633 + this[handlerName].call(this, event);
634 + } catch (e) {
635 + console.error('Error in event handler:', e);
636 + }
637 }
570 - this._listeners[event.type].forEach((listener) => {
571 - listener.call(this, event);
572 - });
573 - return true;
638 +
639 + // Use native EventTarget.dispatchEvent for event listeners
640 + return super.dispatchEvent(event);
641 }
642 }
643
@@ -594,17 +661,17 @@
661 }
662 }
663
597 - // Replace WebSocket with polyfill
598 - window.WebSocket = function (url, protocols) {
664 + // Replace WebSocket with a simple factory function (zero overhead after construction)
665 + window.WebSocket = function WebSocket(url, protocols) {
666 // Use polyfill for same-origin, native for cross-origin
667 if (isSameOrigin(url)) {
601 - console.log(
668 + debugLog(
669 "[WebSocket Polyfill] Using E2EE polyfill for same-origin connection:",
670 url
671 );
672 return new WebSocketPolyfill(url, protocols);
673 } else {
607 - console.log(
674 + debugLog(
675 "[WebSocket Polyfill] Using native WebSocket for cross-origin connection:",
676 url
677 );
@@ -612,17 +679,18 @@
679 }
680 };
681
615 - // Copy static properties
616 - window.WebSocket.CONNECTING = NativeWebSocket.CONNECTING;
617 - window.WebSocket.OPEN = NativeWebSocket.OPEN;
618 - window.WebSocket.CLOSING = NativeWebSocket.CLOSING;
619 - window.WebSocket.CLOSED = NativeWebSocket.CLOSED;
682 + // Copy static constants from WebSocketPolyfill
683 + // Essential for: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
684 + window.WebSocket.CONNECTING = WebSocketPolyfill.CONNECTING; // 0
685 + window.WebSocket.OPEN = WebSocketPolyfill.OPEN; // 1
686 + window.WebSocket.CLOSING = WebSocketPolyfill.CLOSING; // 2
687 + window.WebSocket.CLOSED = WebSocketPolyfill.CLOSED; // 3
688
621 - console.log("[WebSocket Polyfill] Initialized with E2EE and WebSocket protocol support");
689 + debugLog("[WebSocket Polyfill] Initialized with E2EE and WebSocket protocol support");
690
691 // Remove the polyfill script tag after initialization
692 if (currentScript && currentScript.parentNode) {
693 currentScript.parentNode.removeChild(currentScript);
626 - console.log("[WebSocket Polyfill] Script tag removed");
694 + debugLog("[WebSocket Polyfill] Script tag removed");
695 }
696 })();