Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/sdk-js/src/network/wsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
})
41 changes: 37 additions & 4 deletions packages/sdk-js/src/network/wsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSocketRetryOptions>
Expand Down Expand Up @@ -77,6 +106,7 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {
private connectionCount = 0
private connectionAttempt = 0
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined
private pendingClose: { code: number; reason: string } | null = null

constructor({
retry,
Expand Down Expand Up @@ -123,9 +153,10 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {

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)
}
Expand All @@ -147,6 +178,7 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {

removeWsListeners(this.ws)
this.ws = null
this.pendingClose = null
}

private async connect(isRetry = false): Promise<void> {
Expand Down Expand Up @@ -198,7 +230,7 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {
}

if (this.readyState !== WS_STATES.CONNECTING) {
ws.close(1001)
safeWsClose(ws, 1001)
return
}

Expand All @@ -209,7 +241,7 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {

if (this.readyState !== WS_STATES.CONNECTING) {
// User closed the connection during the connection attempt
ws.close(1001)
safeWsClose(ws, 1001)
return
}

Expand All @@ -227,7 +259,8 @@ class WebSocketSession implements Omit<IsoWS, 'onopen'> {
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
}

Expand Down
26 changes: 26 additions & 0 deletions packages/sdk-js/src/v2/live/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
6 changes: 5 additions & 1 deletion packages/sdk-js/src/v2/live/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading