Skip to content
This repository was archived by the owner on Aug 31, 2026. It is now read-only.
Closed
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
24 changes: 1 addition & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@
"author": "OpenHands Team",
"license": "MIT",
"dependencies": {
"@openrouter/sdk": "^1.2.11",
"ws": "^8.20.0"
"@openrouter/sdk": "^1.2.11"
},
"devDependencies": {
"@babel/preset-env": "^7.29.5",
Expand Down
145 changes: 87 additions & 58 deletions src/__tests__/package-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,54 @@
*
* import { RemoteWorkspace } from "@openhands/typescript-client";
*
* These tests pin the new behaviour: the WebSocket modules never throw at
* module load, and the "no WebSocket implementation" condition is reported
* via the existing `onError` callback only when `start()` is called.
* The `ws` fallback is gone. Every runtime this package supports supplies a
* standards-compatible WebSocket global, so the clients read
* `globalThis.WebSocket` directly.
*
* Implementation note: the default Jest runner is CommonJS, so plain
* `require('ws')` *succeeds* in tests and hides the bug. Each test below
* uses `jest.isolateModules` + `jest.doMock('ws', () => { throw ... })` so
* the module-load path is exercised against the same conditions a real
* Node.js ESM consumer hits.
* These tests pin two things: the WebSocket modules never throw at module
* load even when no implementation exists, and the "no WebSocket
* implementation" condition is reported via the existing `onError` callback
* only when `start()` is called. Deleting the global is the real condition a
* consumer would hit, rather than a simulation of it.
*/

const WEBSOCKET_MODULES = [
'../events/websocket-client',
'../events/bash-websocket-client',
] as const;

const mockWsAsUnavailable = (): void => {
jest.doMock('ws', () => {
throw new Error('ws is not available in this environment');
});
const originalWebSocket = globalThis.WebSocket;

/** Remove the global before the module under test reads it at load time. */
const removeWebSocketGlobal = (): void => {
globalThis.WebSocket = undefined as unknown as typeof WebSocket;
};

describe('package imports do not crash when `ws` is unavailable', () => {
afterEach(() => {
globalThis.WebSocket = originalWebSocket;
jest.resetModules();
});

describe('package imports do not crash without a WebSocket implementation', () => {
describe.each(WEBSOCKET_MODULES)('%s', (modulePath) => {
it('does not throw at module load', () => {
expect(() => {
jest.isolateModules(() => {
mockWsAsUnavailable();
removeWebSocketGlobal();
// eslint-disable-next-line @typescript-eslint/no-require-imports
require(modulePath);
});
}).not.toThrow();
});
});

it('importing the package barrel does not throw when `ws` is unavailable', () => {
it('importing the package barrel does not throw', () => {
// This is the exact failure agent-canvas hit:
// import { RemoteWorkspace } from "@openhands/typescript-client";
// would crash because the barrel transitively loads the websocket modules.
expect(() => {
jest.isolateModules(() => {
mockWsAsUnavailable();
removeWebSocketGlobal();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = require('../index');
// Touch a non-WebSocket export to make sure nothing is lazy in a way
Expand All @@ -64,10 +70,10 @@ describe('package imports do not crash when `ws` is unavailable', () => {
}).not.toThrow();
});

it('constructing RemoteWorkspace does not require `ws`', () => {
it('constructing RemoteWorkspace does not need a WebSocket', () => {
expect(() => {
jest.isolateModules(() => {
mockWsAsUnavailable();
removeWebSocketGlobal();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { RemoteWorkspace } = require('../index');
new RemoteWorkspace({
Expand All @@ -79,54 +85,77 @@ describe('package imports do not crash when `ws` is unavailable', () => {
}).not.toThrow();
});

it('WebSocketCallbackClient.start() reports the missing implementation via onError instead of throwing', () => {
let captured: Error | undefined;
it.each([
['../events/websocket-client', 'WebSocketCallbackClient', { conversationId: 'conv-1' }],
['../events/bash-websocket-client', 'BashWebSocketClient', {}],
])(
'%s %s.start() reports the missing implementation via onError',
(modulePath, exportName, extra) => {
let captured: Error | undefined;

jest.isolateModules(() => {
mockWsAsUnavailable();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { WebSocketCallbackClient } = require('../events/websocket-client');
const client = new WebSocketCallbackClient({
host: 'http://example.com',
conversationId: 'conv-1',
callback: () => {},
onError: (err: Error) => {
captured = err;
},
jest.isolateModules(() => {
removeWebSocketGlobal();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const module = require(modulePath);
const client = new module[exportName]({
host: 'http://example.com',
callback: () => {},
onError: (err: Error) => {
captured = err;
},
...(extra as Record<string, unknown>),
});
try {
client.start();
} finally {
client.stop();
}
});
try {
client.start();
} finally {
client.stop();
}
});

expect(captured).toBeInstanceOf(Error);
expect(captured?.message).toMatch(/WebSocket implementation not available/i);
});
expect(captured).toBeInstanceOf(Error);
expect(captured?.message).toMatch(/WebSocket implementation not available/i);
}
);
});

it('BashWebSocketClient.start() reports the missing implementation via onError instead of throwing', () => {
let captured: Error | undefined;
describe('the clients use globalThis.WebSocket when it exists', () => {
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 opens $expectedUrl', ({ 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(() => {
mockWsAsUnavailable();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { BashWebSocketClient } = require('../events/bash-websocket-client');
const client = new BashWebSocketClient({
host: 'http://example.com',
callback: () => {},
onError: (err: Error) => {
captured = err;
},
});
try {
client.start();
} finally {
client.stop();
}
const module = require(modulePath);
const client = new module[exportName](options);
client.start();
client.stop();
});

expect(captured).toBeInstanceOf(Error);
expect(captured?.message).toMatch(/WebSocket implementation not available/i);
expect(urls).toEqual([expectedUrl]);
});
});
22 changes: 5 additions & 17 deletions src/events/bash-websocket-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,11 @@
import { BashEvent } from '../models/workspace';
import { ErrorCallbackType } from './websocket-client';

// 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;
} else {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ws = require('ws');
WebSocketImpl = ws;
} catch {
WebSocketImpl = undefined;
}
}
// See the matching note in `events/websocket-client.ts`: reading the global
// never throws, and the "no WebSocket implementation" condition stays
// deferred to connect() time so importing this module does not crash
// consumers that never use bash event streaming.
const WebSocketImpl: typeof WebSocket | undefined = globalThis.WebSocket;

export interface BashWebSocketClientOptions {
host: string;
Expand All @@ -37,7 +25,7 @@
private apiKey?: string;
private resendMode?: 'all';
private onError?: ErrorCallbackType;
private ws?: any;

Check warning on line 28 in src/events/bash-websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Unexpected any. Specify a different type

Check warning on line 28 in src/events/bash-websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Unexpected any. Specify a different type
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private currentDelay = 1000;
Expand Down
35 changes: 11 additions & 24 deletions src/events/websocket-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,18 @@

import { Event, ConversationCallbackType } from '../types/base';

// Use native WebSocket in browser, ws library in Node.js.
// Every runtime this package supports supplies a standards-compatible
// WebSocket global: browsers, and Node.js since 22.4.
//
// IMPORTANT: this block must never throw. It runs whenever this file is
// imported, and this file is transitively imported by the package barrel
// (via RemoteConversation), so any throw here crashes consumers that
// merely `import { RemoteWorkspace } from "@openhands/typescript-client"`
// even when they have no intent to open a WebSocket. The "no implementation
// available" condition is deferred to connect() time, where it is surfaced
// through the existing onError callback channel.
let WebSocketImpl: any;

if (typeof window !== 'undefined' && window.WebSocket) {
// Browser environment
WebSocketImpl = window.WebSocket;
} else {
// Node.js environment
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ws = require('ws');
WebSocketImpl = ws;
} catch {
// Leave WebSocketImpl undefined; connect() reports the error via onError.
WebSocketImpl = undefined;
}
}
// IMPORTANT: this must never throw. It runs whenever this file is imported,
// and this file is transitively imported by the package barrel (via
// RemoteConversation), so any throw here crashes consumers that merely
// `import { RemoteWorkspace } from "@openhands/typescript-client"` even when
// they have no intent to open a WebSocket. Reading a missing global yields
// undefined rather than throwing, and the "no implementation available"
// condition stays deferred to connect() time, where it is surfaced through
// the existing onError callback channel.
const WebSocketImpl: typeof WebSocket | undefined = globalThis.WebSocket;

/**
* Error callback type for reporting non-fatal errors.
Expand All @@ -51,7 +38,7 @@
private callback: ConversationCallbackType;
private apiKey?: string;
private onError?: ErrorCallbackType;
private ws?: any; // WebSocket instance (browser or Node.js)

Check warning on line 41 in src/events/websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Unexpected any. Specify a different type

Check warning on line 41 in src/events/websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Unexpected any. Specify a different type
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private currentDelay = 1000;
Expand Down Expand Up @@ -111,7 +98,7 @@
this.currentDelay = this.reconnectDelay;
};

this.ws.onmessage = (event: { data: any }) => {

Check warning on line 101 in src/events/websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Unexpected any. Specify a different type

Check warning on line 101 in src/events/websocket-client.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Unexpected any. Specify a different type
try {
const message = typeof event.data === 'string' ? event.data : event.data.toString();
const eventData: Event = JSON.parse(message);
Expand Down
Loading