UNPKG

@mysten/sui

Version:

Sui TypeScript API(Work in Progress)

235 lines (201 loc) 5.93 kB
// 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<T = any> = { method: string; unsubscribe: string; params: any[]; onMessage: (event: T) => void; }; /** * 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<WebsocketClientOptions>; #requestId = 0; #disconnects = 0; #webSocket: WebSocket | null = null; #connectionPromise: Promise<WebSocket> | null = null; #subscriptions = new Set<RpcSubscription>(); #pendingRequests = new Map< number, { resolve: (result: Extract<JsonRpcMessage, { id: number }>) => void; reject: (reason: unknown) => void; timeout: ReturnType<typeof setTimeout>; } >(); 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<T>(method: string, params: any[]): Promise<T> { const webSocket = await this.#setupWebSocket(); return new Promise<Extract<JsonRpcMessage, { id: number }>>((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), }); 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<WebSocket>((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<T>(input: SubscriptionRequest<T>) { 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<any>; 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<number>( this.input.method, this.input.params, ); if (this.subscribed) { this.subscriptionId = newSubscriptionId; } } }