From 710dfb55535759f7835b0fc9a0f5eab9cce900ab Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 27 Aug 2026 17:15:13 -0400 Subject: [PATCH] fix(events): use global WebSocket in Node ESM --- src/__tests__/package-import.test.ts | 67 ++++++++++++++++++++++++++++ src/events/bash-websocket-client.ts | 6 ++- src/events/websocket-client.ts | 11 ++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/__tests__/package-import.test.ts b/src/__tests__/package-import.test.ts index e01e18b9..9a6ab65d 100644 --- a/src/__tests__/package-import.test.ts +++ b/src/__tests__/package-import.test.ts @@ -28,12 +28,20 @@ const WEBSOCKET_MODULES = [ ] as const; const mockWsAsUnavailable = (): void => { + globalThis.WebSocket = undefined as unknown as typeof WebSocket; jest.doMock('ws', () => { throw new Error('ws is not available in this environment'); }); }; describe('package imports do not crash when `ws` is unavailable', () => { + const originalWebSocket = globalThis.WebSocket; + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + jest.resetModules(); + }); + describe.each(WEBSOCKET_MODULES)('%s', (modulePath) => { it('does not throw at module load', () => { expect(() => { @@ -130,3 +138,62 @@ describe('package imports do not crash when `ws` is unavailable', () => { expect(captured?.message).toMatch(/WebSocket implementation not available/i); }); }); + +describe('modern Node.js WebSocket support', () => { + const originalWebSocket = globalThis.WebSocket; + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + jest.resetModules(); + }); + + it.each([ + { + modulePath: '../events/websocket-client', + exportName: 'WebSocketCallbackClient', + options: { + host: 'http://example.com', + conversationId: 'conv-1', + callback: () => {}, + }, + expectedUrl: 'ws://example.com/sockets/events/conv-1', + }, + { + modulePath: '../events/bash-websocket-client', + exportName: 'BashWebSocketClient', + options: { + host: 'http://example.com', + callback: () => {}, + }, + expectedUrl: 'ws://example.com/sockets/bash-events', + }, + ])( + '$exportName uses globalThis.WebSocket', + ({ modulePath, exportName, options, expectedUrl }) => { + const urls: string[] = []; + class FakeWebSocket { + onopen?: () => void; + onmessage?: (event: { data: unknown }) => void; + onclose?: () => void; + onerror?: () => void; + + constructor(url: string) { + urls.push(url); + } + + close(): void {} + } + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const module = require(modulePath); + const client = new module[exportName](options); + client.start(); + client.stop(); + }); + + expect(urls).toEqual([expectedUrl]); + } + ); +}); diff --git a/src/events/bash-websocket-client.ts b/src/events/bash-websocket-client.ts index 94aab776..def53701 100644 --- a/src/events/bash-websocket-client.ts +++ b/src/events/bash-websocket-client.ts @@ -5,14 +5,16 @@ import { BashEvent } from '../models/workspace'; import { ErrorCallbackType } from './websocket-client'; +// Prefer the standards-compatible global in browsers and modern Node.js, +// falling back to ws where CommonJS require is available. // IMPORTANT: this block must never throw. See the matching note in // `events/websocket-client.ts` — the "no WebSocket implementation" // condition is deferred to connect() time so importing this module does // not crash consumers that never use bash event streaming. let WebSocketImpl: any; -if (typeof window !== 'undefined' && window.WebSocket) { - WebSocketImpl = window.WebSocket; +if (typeof globalThis.WebSocket !== 'undefined') { + WebSocketImpl = globalThis.WebSocket; } else { try { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/src/events/websocket-client.ts b/src/events/websocket-client.ts index 99be5ea0..56832e98 100644 --- a/src/events/websocket-client.ts +++ b/src/events/websocket-client.ts @@ -4,7 +4,8 @@ import { Event, ConversationCallbackType } from '../types/base'; -// Use native WebSocket in browser, ws library in Node.js. +// Prefer the standards-compatible global in browsers and modern Node.js, +// falling back to ws where CommonJS require is available. // // IMPORTANT: this block must never throw. It runs whenever this file is // imported, and this file is transitively imported by the package barrel @@ -15,11 +16,11 @@ import { Event, ConversationCallbackType } from '../types/base'; // through the existing onError callback channel. let WebSocketImpl: any; -if (typeof window !== 'undefined' && window.WebSocket) { - // Browser environment - WebSocketImpl = window.WebSocket; +if (typeof globalThis.WebSocket !== 'undefined') { + // Browser or modern Node.js environment + WebSocketImpl = globalThis.WebSocket; } else { - // Node.js environment + // Older Node.js or another environment without a global WebSocket try { // eslint-disable-next-line @typescript-eslint/no-require-imports const ws = require('ws');