diff --git a/packages/sdk-js/src/network/wsClient.test.ts b/packages/sdk-js/src/network/wsClient.test.ts index 258dd39..f86f5a7 100644 --- a/packages/sdk-js/src/network/wsClient.test.ts +++ b/packages/sdk-js/src/network/wsClient.test.ts @@ -736,4 +736,58 @@ describe('WebSocketClient + WebSocketSession', () => { expect(limitCloseSpy).toHaveBeenCalledWith({ code: 1002, reason: 'Test close' }) }) + + it('should sanitize reserved close codes in browser environments', async () => { + const originalWindow = globalThis.window + const originalDocument = globalThis.document + + vi.stubGlobal('window', {}) + vi.stubGlobal('document', {}) + + try { + const closeSpy = vi.fn() + client = new WebSocketClient(partialOptions()) + session = client.createSession('ws://localhost:8080') + session.onclose = closeSpy + await tick() + simulateOpen() + + session.close(1001, 'Aborted') + expect(mockWs.close).toHaveBeenCalledWith(1000) + expect(mockWs.close).not.toHaveBeenCalledWith(1001) + + simulateClose(1000, '') + expect(closeSpy).toHaveBeenCalledWith({ code: 1001, reason: 'Aborted' }) + } finally { + vi.stubGlobal('window', originalWindow) + vi.stubGlobal('document', originalDocument) + } + }) + + it('should fall back to 1000 when browser close throws InvalidAccessError', async () => { + const originalWindow = globalThis.window + const originalDocument = globalThis.document + + vi.stubGlobal('window', {}) + vi.stubGlobal('document', {}) + + mockWs.close = vi.fn((code: number) => { + if (code !== 1000) { + throw new DOMException('Invalid close code', 'InvalidAccessError') + } + }) + + try { + client = new WebSocketClient(partialOptions()) + session = client.createSession('ws://localhost:8080') + await tick() + simulateOpen() + + expect(() => session.close(1006, 'WebSocket connection error')).not.toThrow() + expect(mockWs.close).toHaveBeenLastCalledWith(1000) + } finally { + vi.stubGlobal('window', originalWindow) + vi.stubGlobal('document', originalDocument) + } + }) }) diff --git a/packages/sdk-js/src/network/wsClient.ts b/packages/sdk-js/src/network/wsClient.ts index 436d08b..98759e6 100644 --- a/packages/sdk-js/src/network/wsClient.ts +++ b/packages/sdk-js/src/network/wsClient.ts @@ -33,6 +33,35 @@ function removeWsListeners(ws?: IsoWS | null): void { ws.onclose = null } +/** Browser WebSocket.close() only accepts 1000 or 3000–4999. */ +function isBrowserWebSocketEnvironment(): boolean { + const env = globalThis as typeof globalThis & { + window?: unknown + document?: unknown + } + return env.window !== undefined && env.document !== undefined +} + +function sanitizeCloseCodeForBrowser(code: number): number { + if (code === 1000 || (code >= 3000 && code <= 4999)) { + return code + } + return 1000 +} + +function safeWsClose(ws: IsoWS, code: number): void { + const closeCode = isBrowserWebSocketEnvironment() ? sanitizeCloseCodeForBrowser(code) : code + try { + ws.close(closeCode) + } catch { + try { + ws.close(1000) + } catch { + // ignore + } + } +} + export class WebSocketClient { private readonly baseUrl: string | URL private readonly retry: Required @@ -77,6 +106,7 @@ class WebSocketSession implements Omit { private connectionCount = 0 private connectionAttempt = 0 private connectionTimeoutId: ReturnType | undefined + private pendingClose: { code: number; reason: string } | null = null constructor({ retry, @@ -123,9 +153,10 @@ class WebSocketSession implements Omit { this.clearConnectionTimeout() this._readyState = WS_STATES.CLOSING + this.pendingClose = { code, reason } if (this.ws?.readyState === WS_STATES.OPEN) { - this.ws.close(code) + safeWsClose(this.ws, code) } /* if (this.readyState === WS_STATES.CONNECTING) */ else { this.onWsClose(code, reason) } @@ -147,6 +178,7 @@ class WebSocketSession implements Omit { removeWsListeners(this.ws) this.ws = null + this.pendingClose = null } private async connect(isRetry = false): Promise { @@ -198,7 +230,7 @@ class WebSocketSession implements Omit { } if (this.readyState !== WS_STATES.CONNECTING) { - ws.close(1001) + safeWsClose(ws, 1001) return } @@ -209,7 +241,7 @@ class WebSocketSession implements Omit { if (this.readyState !== WS_STATES.CONNECTING) { // User closed the connection during the connection attempt - ws.close(1001) + safeWsClose(ws, 1001) return } @@ -227,7 +259,8 @@ class WebSocketSession implements Omit { this.ws = null if (this.readyState === WS_STATES.CLOSING) { - this.onWsClose(event.code, event.reason || '') + const pending = this.pendingClose + this.onWsClose(pending?.code ?? event.code, pending?.reason || event.reason || '') return } diff --git a/packages/sdk-js/src/v2/live/session.test.ts b/packages/sdk-js/src/v2/live/session.test.ts index f122ad3..5bf4fb5 100644 --- a/packages/sdk-js/src/v2/live/session.test.ts +++ b/packages/sdk-js/src/v2/live/session.test.ts @@ -146,4 +146,30 @@ describe('LiveV2Session connectSession', () => { }) expect(session.sessionId).toBe('created-session-id') }) + + it('endSession does not throw when abort closes the websocket with a reserved code', async () => { + const existingSession = { + id: 'session-123', + url: 'wss://api.gladia.io/v2/live/ws?token=abc', + created_at: '2026-06-25T10:00:00Z', + } + + mockWsSession.readyState = WS_STATES.OPEN + mockWsSession.close = vi.fn(() => { + throw new DOMException('Invalid close code', 'InvalidAccessError') + }) + + const session = new LiveV2Session({ + options: {}, + existingSession, + httpClient, + webSocketClient, + }) + + await tick() + mockWsSession.onopen?.({ connection: 1, attempt: 1 }) + + expect(() => session.endSession()).not.toThrow() + expect(session.status).toBe('ended') + }) }) diff --git a/packages/sdk-js/src/v2/live/session.ts b/packages/sdk-js/src/v2/live/session.ts index 9004fed..246b074 100644 --- a/packages/sdk-js/src/v2/live/session.ts +++ b/packages/sdk-js/src/v2/live/session.ts @@ -199,7 +199,11 @@ export class LiveV2Session { webSocketSession.onmessage = null webSocketSession.onclose = null webSocketSession.onerror = null - webSocketSession.close(1001, 'Aborted') + try { + webSocketSession.close(1001, 'Aborted') + } catch { + // Abort listeners route exceptions to window.onerror; swallow close failures. + } }) this.webSocketSession = webSocketSession