From fa66511204c5a501ff95cca22edcd88e09baa87b Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Tue, 24 Mar 2026 16:11:55 -0700 Subject: [PATCH 1/3] Remove WebSocket client and streaming APIs from JSON-RPC transport Remove the WebSocket-based subscription system from the JSON-RPC client, including rpc-websocket-client.ts, the subscribe method on JsonRpcHTTPTransport, and related types (JsonRpcTransportSubscribeOptions, Unsubscribe, WebsocketClientOptions). Also removes ws/\@types/ws dev deps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/remove-jsonrpc-websocket.md | 5 + packages/sui/package.json | 4 +- packages/sui/src/jsonRpc/http-transport.ts | 52 ---- packages/sui/src/jsonRpc/index.ts | 1 - .../sui/src/jsonRpc/rpc-websocket-client.ts | 241 ------------------ packages/sui/src/jsonRpc/types/common.ts | 1 - packages/sui/test/e2e/utils/setup.ts | 3 - .../test/unit/client/http-transport.test.ts | 173 ------------- 8 files changed, 6 insertions(+), 474 deletions(-) create mode 100644 .changeset/remove-jsonrpc-websocket.md delete mode 100644 packages/sui/src/jsonRpc/rpc-websocket-client.ts diff --git a/.changeset/remove-jsonrpc-websocket.md b/.changeset/remove-jsonrpc-websocket.md new file mode 100644 index 000000000..9b9d20174 --- /dev/null +++ b/.changeset/remove-jsonrpc-websocket.md @@ -0,0 +1,5 @@ +--- +'@mysten/sui': major +--- + +Remove WebSocket client and streaming subscription APIs from the JSON-RPC transport. The `subscribe` method, `WebSocketConstructor` option, `websocket` option, `JsonRpcTransportSubscribeOptions` type, and `Unsubscribe` type have been removed from the public API. diff --git a/packages/sui/package.json b/packages/sui/package.json index 63b98b2cd..a0f738fdf 100644 --- a/packages/sui/package.json +++ b/packages/sui/package.json @@ -169,7 +169,6 @@ "@parcel/watcher": "^2.5.4", "@types/node": "^25.0.8", "@types/tmp": "^0.2.6", - "@types/ws": "^8.18.1", "cross-env": "^10.1.0", "graphql-config": "^5.1.5", "msw": "^2.12.7", @@ -179,8 +178,7 @@ "vite": "^7.3.1", "vite-tsconfig-paths": "^6.0.4", "vitest": "^4.0.17", - "wait-on": "^9.0.3", - "ws": "^8.19.0" + "wait-on": "^9.0.3" }, "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", diff --git a/packages/sui/src/jsonRpc/http-transport.ts b/packages/sui/src/jsonRpc/http-transport.ts index 0c480a6a8..f404f57e5 100644 --- a/packages/sui/src/jsonRpc/http-transport.ts +++ b/packages/sui/src/jsonRpc/http-transport.ts @@ -3,8 +3,6 @@ import { PACKAGE_VERSION, TARGETED_RPC_VERSION } from '../version.js'; import { JsonRpcError, SuiHTTPStatusError } from './errors.js'; -import type { WebsocketClientOptions } from './rpc-websocket-client.js'; -import { WebsocketClient } from './rpc-websocket-client.js'; /** * An object defining headers to be passed to the RPC server @@ -13,15 +11,11 @@ export type HttpHeaders = { [header: string]: string }; export interface JsonRpcHTTPTransportOptions { fetch?: typeof fetch; - WebSocketConstructor?: typeof WebSocket; url: string; rpc?: { headers?: HttpHeaders; url?: string; }; - websocket?: WebsocketClientOptions & { - url?: string; - }; } export interface JsonRpcTransportRequestOptions { @@ -30,25 +24,13 @@ export interface JsonRpcTransportRequestOptions { signal?: AbortSignal; } -export interface JsonRpcTransportSubscribeOptions { - method: string; - unsubscribe: string; - params: unknown[]; - onMessage: (event: T) => void; - signal?: AbortSignal; -} - export interface JsonRpcTransport { request(input: JsonRpcTransportRequestOptions): Promise; - subscribe( - input: JsonRpcTransportSubscribeOptions, - ): Promise<() => Promise>; } export class JsonRpcHTTPTransport implements JsonRpcTransport { #requestId = 0; #options: JsonRpcHTTPTransportOptions; - #websocketClient?: WebsocketClient; constructor(options: JsonRpcHTTPTransportOptions) { this.#options = options; @@ -66,27 +48,6 @@ export class JsonRpcHTTPTransport implements JsonRpcTransport { return fetchFn(input, init); } - #getWebsocketClient(): WebsocketClient { - if (!this.#websocketClient) { - const WebSocketConstructor = this.#options.WebSocketConstructor ?? WebSocket; - if (!WebSocketConstructor) { - throw new Error( - 'The current environment does not support WebSocket, you can provide a WebSocketConstructor in the options for SuiHTTPTransport.', - ); - } - - this.#websocketClient = new WebsocketClient( - this.#options.websocket?.url ?? this.#options.url, - { - WebSocketConstructor, - ...this.#options.websocket, - }, - ); - } - - return this.#websocketClient; - } - async request(input: JsonRpcTransportRequestOptions): Promise { this.#requestId += 1; @@ -125,17 +86,4 @@ export class JsonRpcHTTPTransport implements JsonRpcTransport { return data.result; } - - async subscribe(input: JsonRpcTransportSubscribeOptions): Promise<() => Promise> { - const unsubscribe = await this.#getWebsocketClient().subscribe(input); - - if (input.signal) { - input.signal.throwIfAborted(); - input.signal.addEventListener('abort', () => { - unsubscribe(); - }); - } - - return async () => !!(await unsubscribe()); - } } diff --git a/packages/sui/src/jsonRpc/index.ts b/packages/sui/src/jsonRpc/index.ts index d058ebbf5..90802a55d 100644 --- a/packages/sui/src/jsonRpc/index.ts +++ b/packages/sui/src/jsonRpc/index.ts @@ -4,7 +4,6 @@ export { type JsonRpcTransport, type JsonRpcTransportRequestOptions, - type JsonRpcTransportSubscribeOptions, type HttpHeaders, type JsonRpcHTTPTransportOptions, JsonRpcHTTPTransport, diff --git a/packages/sui/src/jsonRpc/rpc-websocket-client.ts b/packages/sui/src/jsonRpc/rpc-websocket-client.ts deleted file mode 100644 index 5b227942f..000000000 --- a/packages/sui/src/jsonRpc/rpc-websocket-client.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { JsonRpcError } from './errors.js'; - -function getWebsocketUrl(httpUrl: string): string { - const url = new URL(httpUrl); - url.protocol = url.protocol.replace('http', 'ws'); - return url.toString(); -} - -type JsonRpcMessage = - | { - id: number; - result: never; - error: { - code: number; - message: string; - }; - } - | { - id: number; - result: unknown; - error: never; - } - | { - method: string; - params: NotificationMessageParams; - }; - -type NotificationMessageParams = { - subscription?: number; - result: object; -}; - -type SubscriptionRequest = { - method: string; - unsubscribe: string; - params: any[]; - onMessage: (event: T) => void; - signal?: AbortSignal; -}; - -/** - * Configuration options for the websocket connection - */ -export type WebsocketClientOptions = { - /** - * Custom WebSocket class to use. Defaults to the global WebSocket class, if available. - */ - WebSocketConstructor?: typeof WebSocket; - /** - * Milliseconds before timing out while calling an RPC method - */ - callTimeout?: number; - /** - * Milliseconds between attempts to connect - */ - reconnectTimeout?: number; - /** - * Maximum number of times to try connecting before giving up - */ - maxReconnects?: number; -}; - -export const DEFAULT_CLIENT_OPTIONS = { - // We fudge the typing because we also check for undefined in the constructor: - WebSocketConstructor: (typeof WebSocket !== 'undefined' - ? WebSocket - : undefined) as typeof WebSocket, - callTimeout: 30000, - reconnectTimeout: 3000, - maxReconnects: 5, -} satisfies WebsocketClientOptions; - -export class WebsocketClient { - endpoint: string; - options: Required; - #requestId = 0; - #disconnects = 0; - #webSocket: WebSocket | null = null; - #connectionPromise: Promise | null = null; - #subscriptions = new Set(); - #pendingRequests = new Map< - number, - { - resolve: (result: Extract) => void; - reject: (reason: unknown) => void; - timeout: ReturnType; - } - >(); - - constructor(endpoint: string, options: WebsocketClientOptions = {}) { - this.endpoint = endpoint; - this.options = { ...DEFAULT_CLIENT_OPTIONS, ...options }; - - if (!this.options.WebSocketConstructor) { - throw new Error('Missing WebSocket constructor'); - } - - if (this.endpoint.startsWith('http')) { - this.endpoint = getWebsocketUrl(this.endpoint); - } - } - - async makeRequest(method: string, params: any[], signal?: AbortSignal): Promise { - const webSocket = await this.#setupWebSocket(); - - return new Promise>((resolve, reject) => { - this.#requestId += 1; - this.#pendingRequests.set(this.#requestId, { - resolve: resolve, - reject, - timeout: setTimeout(() => { - this.#pendingRequests.delete(this.#requestId); - reject(new Error(`Request timeout: ${method}`)); - }, this.options.callTimeout), - }); - - signal?.addEventListener('abort', () => { - this.#pendingRequests.delete(this.#requestId); - reject(signal.reason); - }); - - webSocket.send(JSON.stringify({ jsonrpc: '2.0', id: this.#requestId, method, params })); - }).then(({ error, result }) => { - if (error) { - throw new JsonRpcError(error.message, error.code); - } - - return result as T; - }); - } - - #setupWebSocket() { - if (this.#connectionPromise) { - return this.#connectionPromise; - } - - this.#connectionPromise = new Promise((resolve) => { - this.#webSocket?.close(); - this.#webSocket = new this.options.WebSocketConstructor(this.endpoint); - - this.#webSocket.addEventListener('open', () => { - this.#disconnects = 0; - resolve(this.#webSocket!); - }); - - this.#webSocket.addEventListener('close', () => { - this.#disconnects++; - if (this.#disconnects <= this.options.maxReconnects) { - setTimeout(() => { - this.#reconnect(); - }, this.options.reconnectTimeout); - } - }); - - this.#webSocket.addEventListener('message', ({ data }: { data: string }) => { - let json: JsonRpcMessage; - try { - json = JSON.parse(data) as JsonRpcMessage; - } catch (error) { - console.error(new Error(`Failed to parse RPC message: ${data}`, { cause: error })); - return; - } - - if ('id' in json && json.id != null && this.#pendingRequests.has(json.id)) { - const { resolve, timeout } = this.#pendingRequests.get(json.id)!; - - clearTimeout(timeout); - resolve(json); - } else if ('params' in json) { - const { params } = json; - this.#subscriptions.forEach((subscription) => { - if (subscription.subscriptionId === params.subscription) - if (params.subscription === subscription.subscriptionId) { - subscription.onMessage(params.result); - } - }); - } - }); - }); - - return this.#connectionPromise; - } - - async #reconnect() { - this.#webSocket?.close(); - this.#connectionPromise = null; - - return Promise.allSettled( - [...this.#subscriptions].map((subscription) => subscription.subscribe(this)), - ); - } - - async subscribe(input: SubscriptionRequest) { - const subscription = new RpcSubscription(input); - this.#subscriptions.add(subscription); - await subscription.subscribe(this); - return () => subscription.unsubscribe(this); - } -} - -class RpcSubscription { - subscriptionId: number | null = null; - input: SubscriptionRequest; - subscribed = false; - - constructor(input: SubscriptionRequest) { - this.input = input; - } - - onMessage(message: unknown) { - if (this.subscribed) { - this.input.onMessage(message); - } - } - - async unsubscribe(client: WebsocketClient) { - const { subscriptionId } = this; - this.subscribed = false; - if (subscriptionId == null) return false; - this.subscriptionId = null; - - return client.makeRequest(this.input.unsubscribe, [subscriptionId]); - } - - async subscribe(client: WebsocketClient) { - this.subscriptionId = null; - this.subscribed = true; - const newSubscriptionId = await client.makeRequest( - this.input.method, - this.input.params, - this.input.signal, - ); - - if (this.subscribed) { - this.subscriptionId = newSubscriptionId; - } - } -} diff --git a/packages/sui/src/jsonRpc/types/common.ts b/packages/sui/src/jsonRpc/types/common.ts index eafa2ab88..1f2ac8bdd 100644 --- a/packages/sui/src/jsonRpc/types/common.ts +++ b/packages/sui/src/jsonRpc/types/common.ts @@ -2,4 +2,3 @@ // SPDX-License-Identifier: Apache-2.0 export type Order = 'ascending' | 'descending'; -export type Unsubscribe = () => Promise; diff --git a/packages/sui/test/e2e/utils/setup.ts b/packages/sui/test/e2e/utils/setup.ts index 185775bb7..8b5fa7e61 100644 --- a/packages/sui/test/e2e/utils/setup.ts +++ b/packages/sui/test/e2e/utils/setup.ts @@ -6,7 +6,6 @@ import type { ContainerRuntimeClient } from 'testcontainers'; import { getContainerRuntimeClient } from 'testcontainers'; import { retry } from 'ts-retry-promise'; import { expect, inject, it, test } from 'vitest'; -import { WebSocket } from 'ws'; import type { SuiObjectChangePublished } from '../../../src/jsonRpc/index.js'; import { @@ -64,7 +63,6 @@ export class TestToolbox { network: 'localnet', transport: new JsonRpcHTTPTransport({ url, - WebSocketConstructor: WebSocket as never, }), }); this.grpcClient = new SuiGrpcClient({ @@ -338,7 +336,6 @@ export function getClient(url = DEFAULT_FULLNODE_URL): SuiJsonRpcClient { network: 'localnet', transport: new JsonRpcHTTPTransport({ url, - WebSocketConstructor: WebSocket as never, }), }); } diff --git a/packages/sui/test/unit/client/http-transport.test.ts b/packages/sui/test/unit/client/http-transport.test.ts index 30f378ff7..be473828a 100644 --- a/packages/sui/test/unit/client/http-transport.test.ts +++ b/packages/sui/test/unit/client/http-transport.test.ts @@ -1,7 +1,6 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import EventEmitter from 'node:events'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { JsonRpcHTTPTransport } from '../../../src/jsonRpc/index.js'; @@ -70,176 +69,4 @@ describe('JsonRpcHTTPTransport', () => { expect(result).toEqual(mockResult); }); }); - - describe('rpc subscriptions', () => { - let sockets: (WebSocket & EventEmitter)[] = []; - let sentMessages: unknown[] = []; - let subscriptionId = 100; - const results = new Map(); - class MockWebSocket extends EventEmitter { - addEventListener: any; - close: any; - send: any; - - constructor(_url?: string | URL, _protocols?: string | string[]) { - super(); - const socket = this as unknown as WebSocket & EventEmitter; - socket.addEventListener = vi.fn(socket.addListener.bind(socket)); - socket.close = vi.fn(); - socket.send = vi.fn((message: string) => { - const data = JSON.parse(message); - sentMessages.push(data); - - if (data.id && data.method) { - setTimeout(() => { - socket.emit('message', { - data: JSON.stringify({ - jsonrpc: '2.0', - id: data.id, - result: data.method.startsWith('subscribe') ? subscriptionId++ : {}, - ...results.get(data.method), - }), - }); - }); - } - }); - sockets.push(socket); - - setTimeout(() => { - socket.emit('open'); - }, 10); - } - } - const MockWebSocketConstructor = MockWebSocket as unknown as typeof WebSocket; - - beforeEach(() => { - subscriptionId = 100; - sockets = []; - sentMessages = []; - }); - - it('Creates a subscription', async () => { - const transport = new JsonRpcHTTPTransport({ - url: 'http://localhost:4000', - WebSocketConstructor: MockWebSocketConstructor, - }); - const onMessage = vi.fn(); - const unsubscribe = await transport.subscribe({ - method: 'subscribeExample', - unsubscribe: 'unsubscribeExample', - params: [], - onMessage, - }); - - expect(sockets.length).toEqual(1); - const socket = sockets[0]; - - expect(socket.addEventListener).toHaveBeenCalledTimes(3); - expect(socket.addEventListener).toHaveBeenCalledWith('open', expect.any(Function)); - expect(socket.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - expect(socket.addEventListener).toHaveBeenCalledWith('close', expect.any(Function)); - expect(sentMessages).toEqual([ - { - jsonrpc: '2.0', - id: 1, - method: 'subscribeExample', - params: [], - }, - ]); - - expect(onMessage).toHaveBeenCalledTimes(0); - - const mockEvent = { - id: 123, - }; - - socket.emit('message', { - data: JSON.stringify({ - jsonrpc: '2.0', - params: { - subscription: subscriptionId - 1, - result: mockEvent, - }, - }), - }); - - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith(mockEvent); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - await unsubscribe(); - - expect(sentMessages).toEqual([ - { - jsonrpc: '2.0', - id: 1, - method: 'subscribeExample', - params: [], - }, - { - jsonrpc: '2.0', - id: 2, - method: 'unsubscribeExample', - params: [subscriptionId - 1], - }, - ]); - }); - - it('Should reconnect on close', async () => { - const transport = new JsonRpcHTTPTransport({ - url: 'http://localhost:4000', - WebSocketConstructor: MockWebSocketConstructor, - websocket: { - reconnectTimeout: 1, - }, - }); - const onMessage = vi.fn(); - const unsubscribe = await transport.subscribe({ - method: 'subscribeExample', - unsubscribe: 'unsubscribeExample', - params: [], - onMessage, - }); - - expect(sockets.length).toEqual(1); - const socket1 = sockets[0]; - - expect(sentMessages).toEqual([ - { - jsonrpc: '2.0', - id: 1, - method: 'subscribeExample', - params: [], - }, - ]); - - expect(onMessage).toHaveBeenCalledTimes(0); - socket1.emit('close'); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(socket1.close).toHaveBeenCalled(); - expect(sockets.length).toEqual(2); - - const socket2 = sockets[1]; - - expect(socket2.addEventListener).toHaveBeenCalledTimes(3); - expect(socket2.addEventListener).toHaveBeenCalledWith('open', expect.any(Function)); - expect(socket2.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - expect(socket2.addEventListener).toHaveBeenCalledWith('close', expect.any(Function)); - - expect(socket2.send).toHaveBeenCalledTimes(1); - expect(socket2.send).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'subscribeExample', - params: [], - }), - ); - - await unsubscribe(); - }); - }); }); From effa86b261cc3fda9a0089aebb6485d2f5429b99 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Tue, 24 Mar 2026 16:14:35 -0700 Subject: [PATCH 2/3] Change changeset to minor Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/remove-jsonrpc-websocket.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/remove-jsonrpc-websocket.md b/.changeset/remove-jsonrpc-websocket.md index 9b9d20174..db3da72a4 100644 --- a/.changeset/remove-jsonrpc-websocket.md +++ b/.changeset/remove-jsonrpc-websocket.md @@ -1,5 +1,5 @@ --- -'@mysten/sui': major +'@mysten/sui': minor --- Remove WebSocket client and streaming subscription APIs from the JSON-RPC transport. The `subscribe` method, `WebSocketConstructor` option, `websocket` option, `JsonRpcTransportSubscribeOptions` type, and `Unsubscribe` type have been removed from the public API. From 24942fb5a027c9f0dc1b3532b5f97eec6e15e7d1 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Tue, 24 Mar 2026 16:16:13 -0700 Subject: [PATCH 3/3] Update lockfile after removing ws dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b749da45..8343be8f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1209,9 +1209,6 @@ importers: '@types/tmp': specifier: ^0.2.6 version: 0.2.6 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -1242,9 +1239,6 @@ importers: wait-on: specifier: ^9.0.3 version: 9.0.3 - ws: - specifier: ^8.19.0 - version: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) packages/suins: dependencies: