From ff75863f06b2954a5789e41fcb953535c6d1edf2 Mon Sep 17 00:00:00 2001 From: egenthon-cmd Date: Mon, 6 Jul 2026 17:04:48 -0400 Subject: [PATCH 1/2] fix(sdk-js): sanitize ws close codes in browser on live session destroy --- packages/sdk-js/src/network/wsClient.test.ts | 53 ++++++++++++++++++++ packages/sdk-js/src/network/wsClient.ts | 42 ++++++++++++++-- packages/sdk-js/src/v2/live/session.test.ts | 26 ++++++++++ packages/sdk-js/src/v2/live/session.ts | 6 ++- 4 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/sdk-js/src/network/wsClient.test.ts b/packages/sdk-js/src/network/wsClient.test.ts index 258dd39..0e6bde4 100644 --- a/packages/sdk-js/src/network/wsClient.test.ts +++ b/packages/sdk-js/src/network/wsClient.test.ts @@ -736,4 +736,57 @@ 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', { WebSocket: class {} }) + 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) + + 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', { WebSocket: class {} }) + 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..29dfe94 100644 --- a/packages/sdk-js/src/network/wsClient.ts +++ b/packages/sdk-js/src/network/wsClient.ts @@ -33,6 +33,36 @@ 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 + WebSocket?: unknown + } + return env.window !== undefined && env.document !== undefined && env.WebSocket !== 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 +107,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 +154,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 +179,7 @@ class WebSocketSession implements Omit { removeWsListeners(this.ws) this.ws = null + this.pendingClose = null } private async connect(isRetry = false): Promise { @@ -198,7 +231,7 @@ class WebSocketSession implements Omit { } if (this.readyState !== WS_STATES.CONNECTING) { - ws.close(1001) + safeWsClose(ws, 1001) return } @@ -209,7 +242,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 +260,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 From 05e394cc2e04d861eeb03bbd7bec054707d8c15b Mon Sep 17 00:00:00 2001 From: egenthon-cmd Date: Mon, 6 Jul 2026 17:18:36 -0400 Subject: [PATCH 2/2] fix: detection only on window and document --- packages/sdk-js/src/network/wsClient.test.ts | 5 +++-- packages/sdk-js/src/network/wsClient.ts | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk-js/src/network/wsClient.test.ts b/packages/sdk-js/src/network/wsClient.test.ts index 0e6bde4..f86f5a7 100644 --- a/packages/sdk-js/src/network/wsClient.test.ts +++ b/packages/sdk-js/src/network/wsClient.test.ts @@ -741,7 +741,7 @@ describe('WebSocketClient + WebSocketSession', () => { const originalWindow = globalThis.window const originalDocument = globalThis.document - vi.stubGlobal('window', { WebSocket: class {} }) + vi.stubGlobal('window', {}) vi.stubGlobal('document', {}) try { @@ -754,6 +754,7 @@ describe('WebSocketClient + WebSocketSession', () => { session.close(1001, 'Aborted') expect(mockWs.close).toHaveBeenCalledWith(1000) + expect(mockWs.close).not.toHaveBeenCalledWith(1001) simulateClose(1000, '') expect(closeSpy).toHaveBeenCalledWith({ code: 1001, reason: 'Aborted' }) @@ -767,7 +768,7 @@ describe('WebSocketClient + WebSocketSession', () => { const originalWindow = globalThis.window const originalDocument = globalThis.document - vi.stubGlobal('window', { WebSocket: class {} }) + vi.stubGlobal('window', {}) vi.stubGlobal('document', {}) mockWs.close = vi.fn((code: number) => { diff --git a/packages/sdk-js/src/network/wsClient.ts b/packages/sdk-js/src/network/wsClient.ts index 29dfe94..98759e6 100644 --- a/packages/sdk-js/src/network/wsClient.ts +++ b/packages/sdk-js/src/network/wsClient.ts @@ -38,9 +38,8 @@ function isBrowserWebSocketEnvironment(): boolean { const env = globalThis as typeof globalThis & { window?: unknown document?: unknown - WebSocket?: unknown } - return env.window !== undefined && env.document !== undefined && env.WebSocket !== undefined + return env.window !== undefined && env.document !== undefined } function sanitizeCloseCodeForBrowser(code: number): number {