UNPKG

@web5/agent

Version:
169 lines (141 loc) 5.9 kB
import { CryptoUtils } from '@web5/crypto'; import IsomorphicWebSocket from 'isomorphic-ws'; import { JsonRpcId, JsonRpcRequest, JsonRpcResponse, createJsonRpcSubscriptionRequest, parseJson } from './json-rpc.js'; // These were arbitrarily chosen, but can be modified via connect options const CONNECT_TIMEOUT = 3_000; const RESPONSE_TIMEOUT = 30_000; export interface JsonRpcSocketOptions { /** socket connection timeout in milliseconds */ connectTimeout?: number; /** response timeout for rpc requests in milliseconds */ responseTimeout?: number; /** optional connection close handler */ onclose?: () => void; /** optional socket error handler */ onerror?: (error?: any) => void; } /** * JSON RPC Socket Client for WebSocket request/response and long-running subscriptions. * * NOTE: This is temporarily copied over from https://github.com/TBD54566975/dwn-server/blob/main/src/json-rpc-socket.ts * This was done in order to avoid taking a dependency on the `dwn-server`, until a future time when there will be a `clients` package. */ export class JsonRpcSocket { private messageHandlers: Map<JsonRpcId, (event: { data: any }) => void> = new Map(); private constructor(private socket: IsomorphicWebSocket, private responseTimeout: number) {} static async connect(url: string, options: JsonRpcSocketOptions = {}): Promise<JsonRpcSocket> { const { connectTimeout = CONNECT_TIMEOUT, responseTimeout = RESPONSE_TIMEOUT, onclose, onerror } = options; const socket = new IsomorphicWebSocket(url); if (!onclose) { socket.onclose = ():void => { console.info(`JSON RPC Socket close ${url}`); }; } else { socket.onclose = onclose; } if (!onerror) { socket.onerror = (error?: any):void => { console.error(`JSON RPC Socket error ${url}`, error); }; } else { socket.onerror = onerror; } return new Promise<JsonRpcSocket>((resolve, reject) => { socket.addEventListener('open', () => { const jsonRpcSocket = new JsonRpcSocket(socket, responseTimeout); socket.addEventListener('message', (event: { data: any }) => { const jsonRpcResponse = parseJson(event.data) as JsonRpcResponse; const handler = jsonRpcSocket.messageHandlers.get(jsonRpcResponse.id); if (handler) { handler(event); } }); resolve(jsonRpcSocket); }); socket.addEventListener('error', (error: any) => { reject(error); }); setTimeout(() => reject, connectTimeout); }); } close(): void { this.socket.close(); } /** * Sends a JSON-RPC request through the socket and waits for a single response. */ async request(request: JsonRpcRequest): Promise<JsonRpcResponse> { return new Promise((resolve, reject) => { request.id ??= CryptoUtils.randomUuid(); const handleResponse = (event: { data: any }):void => { const jsonRpsResponse = parseJson(event.data) as JsonRpcResponse; if (jsonRpsResponse.id === request.id) { // if the incoming response id matches the request id, we will remove the listener and resolve the response this.messageHandlers.delete(request.id); return resolve(jsonRpsResponse); } }; // add the listener to the map of message handlers this.messageHandlers.set(request.id, handleResponse); this.send(request); // reject this promise if we don't receive any response back within the timeout period setTimeout(() => { this.messageHandlers.delete(request.id!); reject(new Error('request timed out')); }, this.responseTimeout); }); } /** * Sends a JSON-RPC request through the socket and keeps a listener open to read associated responses as they arrive. * Returns a close method to clean up the listener. */ async subscribe(request: JsonRpcRequest, listener: (response: JsonRpcResponse) => void): Promise<{ response: JsonRpcResponse; close?: () => Promise<void>; }> { if (!request.method.startsWith('rpc.subscribe.')) { throw new Error('subscribe rpc requests must include the `rpc.subscribe` prefix'); } if (!request.subscription) { throw new Error('subscribe rpc requests must include subscribe options'); } const subscriptionId = request.subscription.id; const socketEventListener = (event: { data: any }):void => { const jsonRpcResponse = parseJson(event.data.toString()) as JsonRpcResponse; if (jsonRpcResponse.id === subscriptionId) { if (jsonRpcResponse.error !== undefined) { // remove the event listener upon receipt of a JSON RPC Error. this.messageHandlers.delete(subscriptionId); this.closeSubscription(subscriptionId); } listener(jsonRpcResponse); } }; this.messageHandlers.set(subscriptionId, socketEventListener); const response = await this.request(request); if (response.error) { this.messageHandlers.delete(subscriptionId); return { response }; } // clean up listener and create a `rpc.subscribe.close` message to use when closing this JSON RPC subscription const close = async (): Promise<void> => { this.messageHandlers.delete(subscriptionId); await this.closeSubscription(subscriptionId); }; return { response, close }; } private closeSubscription(id: JsonRpcId): Promise<JsonRpcResponse> { const requestId = CryptoUtils.randomUuid(); const request = createJsonRpcSubscriptionRequest(requestId, 'close', id, {}); return this.request(request); } /** * Sends a JSON-RPC request through the socket. You must subscribe to a message listener separately to capture the response. */ send(request: JsonRpcRequest):void { this.socket.send(JSON.stringify(request)); } }