UNPKG

opnet

Version:

The perfect library for building Bitcoin-based applications.

868 lines (867 loc) 33.7 kB
import Long from 'long'; import { OPNetTransactionTypes } from '../interfaces/opnet/OPNetTransactionTypes.js'; import { AbstractRpcProvider } from './AbstractRpcProvider.js'; import { JSONRpcMethods } from './interfaces/JSONRpcMethods.js'; import { clearProtobufCache, ConnectionState, DEFAULT_CONFIG, getConnectionStateName, getProtobufType, InternalError, loadProtobufSchema, OPNetError, SubscriptionType, WebSocketClientEvent, WebSocketRequestOpcode, WebSocketResponseOpcode, } from './websocket/index.js'; const METHOD_MAPPINGS = { [JSONRpcMethods.BLOCK_BY_NUMBER]: { requestOpcode: WebSocketRequestOpcode.GET_BLOCK_NUMBER, responseOpcode: WebSocketResponseOpcode.BLOCK_NUMBER, requestType: 'GetBlockNumberRequest', responseType: 'GetBlockNumberResponse', }, [JSONRpcMethods.GET_BLOCK_BY_NUMBER]: { requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_NUMBER, responseOpcode: WebSocketResponseOpcode.BLOCK, requestType: 'GetBlockByNumberRequest', responseType: 'BlockResponse', }, [JSONRpcMethods.GET_BLOCK_BY_HASH]: { requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_HASH, responseOpcode: WebSocketResponseOpcode.BLOCK, requestType: 'GetBlockByHashRequest', responseType: 'BlockResponse', }, [JSONRpcMethods.GET_BLOCK_BY_CHECKSUM]: { requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_CHECKSUM, responseOpcode: WebSocketResponseOpcode.BLOCK, requestType: 'GetBlockByChecksumRequest', responseType: 'BlockResponse', }, [JSONRpcMethods.BLOCK_WITNESS]: { requestOpcode: WebSocketRequestOpcode.GET_BLOCK_WITNESS, responseOpcode: WebSocketResponseOpcode.BLOCK_WITNESS, requestType: 'GetBlockWitnessRequest', responseType: 'BlockWitnessResponse', }, [JSONRpcMethods.GAS]: { requestOpcode: WebSocketRequestOpcode.GET_GAS, responseOpcode: WebSocketResponseOpcode.GAS, requestType: 'GetGasRequest', responseType: 'GasResponse', }, [JSONRpcMethods.GET_TRANSACTION_BY_HASH]: { requestOpcode: WebSocketRequestOpcode.GET_TRANSACTION_BY_HASH, responseOpcode: WebSocketResponseOpcode.TRANSACTION, requestType: 'GetTransactionByHashRequest', responseType: 'TransactionResponse', }, [JSONRpcMethods.GET_TRANSACTION_RECEIPT]: { requestOpcode: WebSocketRequestOpcode.GET_TRANSACTION_RECEIPT, responseOpcode: WebSocketResponseOpcode.TRANSACTION_RECEIPT, requestType: 'GetTransactionReceiptRequest', responseType: 'TransactionReceiptResponse', }, [JSONRpcMethods.BROADCAST_TRANSACTION]: { requestOpcode: WebSocketRequestOpcode.BROADCAST_TRANSACTION, responseOpcode: WebSocketResponseOpcode.BROADCAST_RESULT, requestType: 'BroadcastTransactionRequest', responseType: 'BroadcastTransactionResponse', }, [JSONRpcMethods.TRANSACTION_PREIMAGE]: { requestOpcode: WebSocketRequestOpcode.GET_PREIMAGE, responseOpcode: WebSocketResponseOpcode.PREIMAGE, requestType: 'GetPreimageRequest', responseType: 'PreimageResponse', }, [JSONRpcMethods.GET_BALANCE]: { requestOpcode: WebSocketRequestOpcode.GET_BALANCE, responseOpcode: WebSocketResponseOpcode.BALANCE, requestType: 'GetBalanceRequest', responseType: 'BalanceResponse', }, [JSONRpcMethods.GET_UTXOS]: { requestOpcode: WebSocketRequestOpcode.GET_UTXOS, responseOpcode: WebSocketResponseOpcode.UTXOS, requestType: 'GetUTXOsRequest', responseType: 'UTXOsResponse', }, [JSONRpcMethods.PUBLIC_KEY_INFO]: { requestOpcode: WebSocketRequestOpcode.GET_PUBLIC_KEY_INFO, responseOpcode: WebSocketResponseOpcode.PUBLIC_KEY_INFO, requestType: 'GetPublicKeyInfoRequest', responseType: 'PublicKeyInfoResponse', }, [JSONRpcMethods.CHAIN_ID]: { requestOpcode: WebSocketRequestOpcode.GET_CHAIN_ID, responseOpcode: WebSocketResponseOpcode.CHAIN_ID, requestType: 'GetChainIdRequest', responseType: 'ChainIdResponse', }, [JSONRpcMethods.REORG]: { requestOpcode: WebSocketRequestOpcode.GET_REORG, responseOpcode: WebSocketResponseOpcode.REORG, requestType: 'GetReorgRequest', responseType: 'ReorgResponse', }, [JSONRpcMethods.GET_CODE]: { requestOpcode: WebSocketRequestOpcode.GET_CODE, responseOpcode: WebSocketResponseOpcode.CODE, requestType: 'GetCodeRequest', responseType: 'CodeResponse', }, [JSONRpcMethods.GET_STORAGE_AT]: { requestOpcode: WebSocketRequestOpcode.GET_STORAGE_AT, responseOpcode: WebSocketResponseOpcode.STORAGE, requestType: 'GetStorageAtRequest', responseType: 'StorageResponse', }, [JSONRpcMethods.CALL]: { requestOpcode: WebSocketRequestOpcode.CALL, responseOpcode: WebSocketResponseOpcode.CALL_RESULT, requestType: 'CallRequest', responseType: 'CallResponse', }, [JSONRpcMethods.LATEST_EPOCH]: { requestOpcode: WebSocketRequestOpcode.GET_LATEST_EPOCH, responseOpcode: WebSocketResponseOpcode.EPOCH, requestType: 'GetLatestEpochRequest', responseType: 'EpochResponse', }, [JSONRpcMethods.GET_EPOCH_BY_NUMBER]: { requestOpcode: WebSocketRequestOpcode.GET_EPOCH_BY_NUMBER, responseOpcode: WebSocketResponseOpcode.EPOCH, requestType: 'GetEpochByNumberRequest', responseType: 'EpochResponse', }, [JSONRpcMethods.GET_EPOCH_BY_HASH]: { requestOpcode: WebSocketRequestOpcode.GET_EPOCH_BY_HASH, responseOpcode: WebSocketResponseOpcode.EPOCH, requestType: 'GetEpochByHashRequest', responseType: 'EpochResponse', }, [JSONRpcMethods.GET_EPOCH_TEMPLATE]: { requestOpcode: WebSocketRequestOpcode.GET_EPOCH_TEMPLATE, responseOpcode: WebSocketResponseOpcode.EPOCH_TEMPLATE, requestType: 'GetEpochTemplateRequest', responseType: 'EpochTemplateResponse', }, [JSONRpcMethods.SUBMIT_EPOCH]: { requestOpcode: WebSocketRequestOpcode.SUBMIT_EPOCH, responseOpcode: WebSocketResponseOpcode.EPOCH_SUBMIT_RESULT, requestType: 'SubmitEpochRequest', responseType: 'SubmitEpochResponse', }, }; export class WebSocketRpcProvider extends AbstractRpcProvider { config; pendingRequests = new Map(); subscriptions = new Map(); eventHandlers = new Map(); socket = null; state = ConnectionState.DISCONNECTED; requestId = 0; reconnectAttempt = 0; pingTimeout = null; sessionId = null; userRequestedDisconnect = false; protoRoot = null; protoTypes = new Map(); constructor(url, network, config) { super(network); this.config = { ...DEFAULT_CONFIG, url, ...config }; } getState() { return this.state; } isReady() { return this.state === ConnectionState.READY; } async connect() { if (this.state !== ConnectionState.DISCONNECTED) { throw new Error(`Cannot connect: current state is ${getConnectionStateName(this.state)}`); } this.state = ConnectionState.CONNECTING; this.userRequestedDisconnect = false; try { const httpUrl = this.config.url.replace(/^ws/, 'http'); this.protoRoot = await loadProtobufSchema(httpUrl); this.protoTypes.clear(); await this.connectWebSocket(); await this.performHandshake(); this.schedulePing(); this.reconnectAttempt = 0; this.emit(WebSocketClientEvent.CONNECTED, undefined); } catch (error) { this.state = ConnectionState.DISCONNECTED; throw error; } } disconnect() { if (this.state === ConnectionState.DISCONNECTED) { return; } this.userRequestedDisconnect = true; this.state = ConnectionState.CLOSING; this.cancelPing(); this.cleanupPendingRequests(new Error('Connection closed')); if (this.socket) { this.socket.close(1000, 'Client disconnect'); this.socket = null; } this.state = ConnectionState.DISCONNECTED; this.emit(WebSocketClientEvent.DISCONNECTED, undefined); } on(event, handler) { let handlers = this.eventHandlers.get(event); if (!handlers) { handlers = new Set(); this.eventHandlers.set(event, handlers); } handlers.add(handler); } off(event, handler) { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.delete(handler); } } async subscribeBlocks(handler) { if (this.state !== ConnectionState.READY) { throw new Error('Not connected'); } const type = this.getType('SubscribeBlocksRequest'); const message = type.create({}); const encodedPayload = type.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(WebSocketRequestOpcode.SUBSCRIBE_BLOCKS, requestId, encodedPayload); await this.sendRequest(requestId, fullMessage); this.subscriptions.set(SubscriptionType.BLOCKS, handler); } async subscribeEpochs(handler) { if (this.state !== ConnectionState.READY) { throw new Error('Not connected'); } const type = this.getType('SubscribeEpochsRequest'); const message = type.create({}); const encodedPayload = type.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(WebSocketRequestOpcode.SUBSCRIBE_EPOCHS, requestId, encodedPayload); await this.sendRequest(requestId, fullMessage); this.subscriptions.set(SubscriptionType.EPOCHS, handler); } async unsubscribe(subscriptionType) { if (this.state !== ConnectionState.READY) { throw new Error('Not connected'); } const type = this.getType('UnsubscribeRequest'); const messageData = this.buildMessageByFieldId(type, { 2: subscriptionType, }); const message = type.create(messageData); const encodedPayload = type.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(WebSocketRequestOpcode.UNSUBSCRIBE, requestId, encodedPayload); await this.sendRequest(requestId, fullMessage); this.subscriptions.delete(subscriptionType); } clearCache() { clearProtobufCache(); this.protoTypes.clear(); this.protoRoot = null; } async _send(payload) { if (this.state !== ConnectionState.READY) { throw new Error('WebSocket not connected. Call connect() first.'); } const isArray = Array.isArray(payload); const payloads = isArray ? payload : [payload]; const results = []; for (const p of payloads) { const result = await this.sendJsonRpcRequest(p); results.push(result); } return isArray ? [results] : results; } providerUrl(url) { url = url.trim(); if (url.endsWith('/')) { url = url.slice(0, -1); } if (!url.startsWith('ws://') && !url.startsWith('wss://')) { url = url.replace(/^http/, 'ws'); } return url; } async sendJsonRpcRequest(payload) { const mapping = METHOD_MAPPINGS[payload.method]; if (!mapping) { throw new Error(`Unsupported method: ${payload.method}`); } const requestType = this.getType(mapping.requestType); const params = Array.isArray(payload.params) ? payload.params : []; const fieldIdMap = this.translateJsonRpcParamsToFieldIds(payload.method, params); const protoPayload = this.buildMessageByFieldId(requestType, fieldIdMap); const message = requestType.create(protoPayload); const encodedPayload = requestType.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(mapping.requestOpcode, requestId, encodedPayload); const responseData = await this.sendRequest(requestId, fullMessage); const responseType = this.getType(mapping.responseType); const decoded = responseType.decode(responseData); const responseObj = responseType.toObject(decoded, { longs: String, bytes: String, defaults: true, }); const result = this.translateProtoResponse(payload.method, responseObj, responseType); return { jsonrpc: '2.0', id: payload.id ?? null, result, }; } translateJsonRpcParamsToFieldIds(method, params) { switch (method) { case JSONRpcMethods.BLOCK_BY_NUMBER: return {}; case JSONRpcMethods.GET_BLOCK_BY_NUMBER: return { 2: { 1: Long.fromString(String(params[0])) }, 3: params[1] ?? false, }; case JSONRpcMethods.GET_BLOCK_BY_HASH: return { 2: { 2: params[0] }, 3: params[1] ?? false, }; case JSONRpcMethods.GET_BLOCK_BY_CHECKSUM: return { 2: { 3: params[0] }, 3: params[1] ?? false, }; case JSONRpcMethods.BLOCK_WITNESS: return { 2: Long.fromString(String(params[0] ?? -1)), 3: params[1], 4: params[2], 5: params[3], }; case JSONRpcMethods.GAS: return {}; case JSONRpcMethods.GET_TRANSACTION_BY_HASH: return { 2: params[0] }; case JSONRpcMethods.GET_TRANSACTION_RECEIPT: return { 2: params[0] }; case JSONRpcMethods.BROADCAST_TRANSACTION: return { 2: params[0], 3: params[1] ?? false, }; case JSONRpcMethods.TRANSACTION_PREIMAGE: return {}; case JSONRpcMethods.GET_BALANCE: return { 2: String(params[0]), 3: params[1] ?? true, }; case JSONRpcMethods.GET_UTXOS: return { 2: String(params[0]), 3: params[1] ?? true, }; case JSONRpcMethods.PUBLIC_KEY_INFO: return { 2: params[0] }; case JSONRpcMethods.CHAIN_ID: return {}; case JSONRpcMethods.REORG: return { 2: params[0], }; case JSONRpcMethods.GET_CODE: return { 2: String(params[0]), 3: params[1] ?? false, }; case JSONRpcMethods.GET_STORAGE_AT: return { 2: String(params[0]), 3: params[1], 4: params[2] ?? true, }; case JSONRpcMethods.CALL: return { 2: String(params[0]), 3: params[1], 4: params[2], 5: params[3], }; case JSONRpcMethods.LATEST_EPOCH: return {}; case JSONRpcMethods.GET_EPOCH_BY_NUMBER: return { 2: Long.fromString(String(params[0])), }; case JSONRpcMethods.GET_EPOCH_BY_HASH: return { 2: params[0], }; case JSONRpcMethods.GET_EPOCH_TEMPLATE: return {}; case JSONRpcMethods.SUBMIT_EPOCH: { const submitParams = params[0]; return { 2: submitParams.epochNumber, 3: submitParams.targetHash, 4: submitParams.salt, 5: submitParams.mldsaPublicKey, 6: submitParams.graffiti, 7: submitParams.signature, }; } default: return {}; } } convertOPNetTypeToString(value) { switch (value) { case 1: return OPNetTransactionTypes.Deployment; case 2: return OPNetTransactionTypes.Interaction; case 0: default: return OPNetTransactionTypes.Generic; } } convertTransaction(tx) { const txType = this.protoRoot?.lookupType('TransactionForAPI'); if (!txType) { return tx; } const opnetTypeField = this.getFieldById(txType, 19); if (opnetTypeField && tx[opnetTypeField.name] !== undefined) { tx[opnetTypeField.name] = this.convertOPNetTypeToString(tx[opnetTypeField.name]); } const interactionField = this.getFieldById(txType, 20); if (interactionField && tx[interactionField.name]) { const interactionData = tx[interactionField.name]; Object.assign(tx, interactionData); delete tx[interactionField.name]; } const deploymentField = this.getFieldById(txType, 21); if (deploymentField && tx[deploymentField.name]) { const deploymentData = tx[deploymentField.name]; Object.assign(tx, deploymentData); delete tx[deploymentField.name]; } return tx; } convertBlockResponse(block) { const blockType = this.protoRoot?.lookupType('BlockResponse'); if (!blockType) { return block; } const txField = this.getFieldById(blockType, 22); if (txField && Array.isArray(block[txField.name])) { block[txField.name] = block[txField.name].map((tx) => this.convertTransaction(tx)); } return block; } translateProtoResponse(method, response, responseType) { switch (method) { case JSONRpcMethods.BLOCK_BY_NUMBER: { const fieldName = this.getFieldNameById(responseType, 1); return fieldName ? response[fieldName] : undefined; } case JSONRpcMethods.CHAIN_ID: { const fieldName = this.getFieldNameById(responseType, 1); return fieldName ? response[fieldName] : undefined; } case JSONRpcMethods.GET_BALANCE: { const fieldName = this.getFieldNameById(responseType, 1); return fieldName ? response[fieldName] : undefined; } case JSONRpcMethods.GET_BLOCK_BY_NUMBER: case JSONRpcMethods.GET_BLOCK_BY_HASH: case JSONRpcMethods.GET_BLOCK_BY_CHECKSUM: { return this.convertBlockResponse(response); } case JSONRpcMethods.GET_TRANSACTION_BY_HASH: { const txResponseType = this.protoRoot?.lookupType('TransactionResponse'); if (txResponseType) { const txField = this.getFieldById(txResponseType, 1); if (txField && response[txField.name]) { response[txField.name] = this.convertTransaction(response[txField.name]); } } return response; } default: return response; } } connectWebSocket() { return new Promise((resolve, reject) => { const url = this.buildWebSocketUrl(); const timeout = setTimeout(() => { reject(new Error(`Connection timeout after ${this.config.connectTimeout}ms`)); }, this.config.connectTimeout); try { this.socket = new WebSocket(url); this.socket.binaryType = 'arraybuffer'; this.socket.onopen = () => { clearTimeout(timeout); this.state = ConnectionState.CONNECTED; resolve(); }; this.socket.onerror = (event) => { clearTimeout(timeout); reject(new Error(`WebSocket error: ${event}`)); }; this.socket.onclose = (event) => { this.handleClose(event); }; this.socket.onmessage = (event) => { this.handleMessage(event.data); }; } catch (error) { clearTimeout(timeout); reject(error instanceof Error ? error : new Error(String(error))); } }); } buildWebSocketUrl() { let url = this.config.url.trim(); if (url.endsWith('/')) { url = url.slice(0, -1); } if (!url.startsWith('ws://') && !url.startsWith('wss://')) { url = url.replace(/^http/, 'ws'); } if (!url.includes('/api/v1/ws')) { url = `${url}/api/v1/ws`; } return url; } async performHandshake() { this.state = ConnectionState.HANDSHAKING; const type = this.getType('HandshakeRequest'); const messageData = this.buildMessageByFieldId(type, { 1: 1, 2: 'opnet-js', 3: '1.0.0', }); const message = type.create(messageData); const encodedPayload = type.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(WebSocketRequestOpcode.HANDSHAKE, requestId, encodedPayload); const responseData = await this.sendRequest(requestId, fullMessage, this.config.handshakeTimeout); const responseType = this.getType('HandshakeResponse'); const decoded = responseType.decode(responseData); const response = responseType.toObject(decoded, { longs: String, bytes: Uint8Array, defaults: true, }); const sessionIdFieldName = this.getFieldNameById(responseType, 2); if (sessionIdFieldName && response[sessionIdFieldName]) { this.sessionId = response[sessionIdFieldName]; } this.state = ConnectionState.READY; } buildMessageByFieldId(type, valuesByFieldId) { const result = {}; for (const [fieldIdStr, value] of Object.entries(valuesByFieldId)) { const fieldId = parseInt(fieldIdStr, 10); const field = this.getFieldById(type, fieldId); if (field) { if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Long)) { const keys = Object.keys(value); const isFieldIdMap = keys.length > 0 && keys.every((k) => /^\d+$/.test(k)); if (isFieldIdMap) { const nestedType = this.getNestedType(field.type); if (nestedType) { result[field.name] = this.buildMessageByFieldId(nestedType, value); } else { result[field.name] = value; } } else { result[field.name] = value; } } else { result[field.name] = value; } } } return result; } getFieldById(type, fieldId) { for (const field of type.fieldsArray) { if (field.id === fieldId) { return { name: field.name, type: field.type }; } } return undefined; } getFieldNameById(type, fieldId) { const field = this.getFieldById(type, fieldId); return field?.name; } getNestedType(typeName) { if (!this.protoRoot) return undefined; try { return getProtobufType(this.protoRoot, typeName); } catch { return undefined; } } getType(typeName) { let type = this.protoTypes.get(typeName); if (!type) { if (!this.protoRoot) { throw new Error('Protobuf schema not loaded'); } type = getProtobufType(this.protoRoot, typeName); this.protoTypes.set(typeName, type); } return type; } nextRequestId() { this.requestId = (this.requestId + 1) & 0xffffffff; return this.requestId; } buildMessage(opcode, requestId, payload) { const message = new Uint8Array(1 + 4 + payload.length); message[0] = opcode; message[1] = requestId & 0xff; message[2] = (requestId >> 8) & 0xff; message[3] = (requestId >> 16) & 0xff; message[4] = (requestId >> 24) & 0xff; message.set(payload, 5); return message; } sendRequest(requestId, message, timeout = this.config.requestTimeout) { return new Promise((resolve, reject) => { if (this.pendingRequests.size >= this.config.maxPendingRequests) { reject(new Error('Too many pending requests')); return; } const timeoutHandle = setTimeout(() => { this.pendingRequests.delete(requestId); reject(new Error(`Request timeout after ${timeout}ms`)); }, timeout); this.pendingRequests.set(requestId, { resolve, reject, timeout: timeoutHandle, }); this.send(message); }); } send(data) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { throw new Error('WebSocket not connected'); } this.socket.send(data); } handleMessage(data) { const buffer = new Uint8Array(data); if (buffer.length < 5) { console.error('Invalid message: too short'); return; } const opcode = buffer[0]; if (opcode === WebSocketResponseOpcode.NEW_BLOCK_NOTIFICATION) { this.handleBlockNotification(buffer.slice(1)); return; } if (opcode === WebSocketResponseOpcode.NEW_EPOCH_NOTIFICATION) { this.handleEpochNotification(buffer.slice(1)); return; } const requestId = buffer[1] | (buffer[2] << 8) | (buffer[3] << 16) | (buffer[4] << 24); const payload = buffer.slice(5); if (opcode === WebSocketResponseOpcode.ERROR) { this.handleErrorResponse(requestId, payload); return; } const pending = this.pendingRequests.get(requestId); if (pending) { clearTimeout(pending.timeout); this.pendingRequests.delete(requestId); pending.resolve(payload); } } handleErrorResponse(requestId, payload) { const pending = this.pendingRequests.get(requestId); if (!pending) return; clearTimeout(pending.timeout); this.pendingRequests.delete(requestId); try { const type = this.getType('ErrorResponse'); const decoded = type.decode(payload); const error = type.toObject(decoded, { longs: Number, bytes: Uint8Array, defaults: true, }); pending.reject(new OPNetError(error.code ?? InternalError.INTERNAL_ERROR, error.message, error.data)); } catch (e) { pending.reject(new Error('Failed to parse error response')); } } handleBlockNotification(payload) { const handler = this.subscriptions.get(SubscriptionType.BLOCKS); if (!handler) return; try { const type = this.getType('BlockNotification'); const decoded = type.decode(payload); const block = type.toObject(decoded, { longs: String, defaults: true, }); const notification = { blockNumber: BigInt(block.block_number || '0'), blockHash: block.block_hash || '', previousBlockHash: block.previous_block_hash || '', timestamp: BigInt(block.timestamp || '0'), }; handler(notification); this.emit(WebSocketClientEvent.BLOCK, notification); } catch (e) { console.error('Failed to parse block notification:', e); } } handleEpochNotification(payload) { const handler = this.subscriptions.get(SubscriptionType.EPOCHS); if (!handler) return; try { const type = this.getType('EpochNotification'); const decoded = type.decode(payload); const epoch = type.toObject(decoded, { longs: String, defaults: true, }); const notification = { epochNumber: BigInt(epoch.epoch_number || '0'), epochHash: epoch.epoch_hash || '', timestamp: BigInt(epoch.timestamp || '0'), }; handler(notification); this.emit(WebSocketClientEvent.EPOCH, notification); } catch (e) { console.error('Failed to parse epoch notification:', e); } } handleClose(event) { const wasReady = this.state === ConnectionState.READY; this.state = ConnectionState.DISCONNECTED; this.cancelPing(); if (wasReady && this.config.autoReconnect && !this.userRequestedDisconnect) { void this.reconnect(); } else { this.cleanupPendingRequests(new Error(`Connection closed: ${event.code} ${event.reason}`)); this.emit(WebSocketClientEvent.DISCONNECTED, { code: event.code, reason: event.reason, }); } } async reconnect() { if (this.reconnectAttempt >= this.config.maxReconnectAttempts) { this.emit(WebSocketClientEvent.ERROR, new Error('Max reconnection attempts exceeded')); return; } this.state = ConnectionState.RECONNECTING; this.reconnectAttempt++; this.clearCache(); const delay = Math.min(this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt - 1), this.config.reconnectMaxDelay); const jitter = Math.random() * 0.3 * delay; await this.sleep(delay + jitter); try { this.state = ConnectionState.DISCONNECTED; await this.connect(); await this.resubscribe(); } catch (e) { console.warn(`Reconnect attempt ${this.reconnectAttempt} failed:`, e); void this.reconnect(); } } async resubscribe() { const handlers = new Map(this.subscriptions); this.subscriptions.clear(); for (const [type, handler] of handlers) { try { if (type === SubscriptionType.BLOCKS) { await this.subscribeBlocks(handler); } else if (type === SubscriptionType.EPOCHS) { await this.subscribeEpochs(handler); } } catch (e) { console.error(`Failed to resubscribe to ${type}:`, e); } } } cleanupPendingRequests(error) { for (const [_id, pending] of this.pendingRequests) { clearTimeout(pending.timeout); pending.reject(error); } this.pendingRequests.clear(); } schedulePing() { this.cancelPing(); this.pingTimeout = setTimeout(() => { if (this.state === ConnectionState.READY) { try { this.ping(); } catch (e) { console.warn('Ping failed:', e); } this.schedulePing(); } }, this.config.pingInterval); } cancelPing() { if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; } } ping() { const type = this.getType('PingRequest'); const message = type.create({ timestamp: Long.fromNumber(Date.now()) }); const encodedPayload = type.encode(message).finish(); const requestId = this.nextRequestId(); const fullMessage = this.buildMessage(WebSocketRequestOpcode.PING, requestId, encodedPayload); this.send(fullMessage); } emit(event, data) { const handlers = this.eventHandlers.get(event); if (handlers) { for (const handler of handlers) { try { handler(data); } catch (e) { console.error(`Error in event handler for ${event}:`, e); } } } } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }