opnet
Version:
The perfect library for building Bitcoin-based applications.
868 lines (867 loc) • 33.7 kB
JavaScript
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));
}
}