@stalkchain/grpc-pool
Version:
High-availability gRPC connection pooling module with active-active configuration, deduplication, and stale connection detection
264 lines • 9.52 kB
JavaScript
"use strict";
/**
* lib/client.ts - Individual gRPC client wrapper
*
* Wraps a single Triton-One Yellowstone gRPC connection with simple interface.
* Handles connection, subscription, and streaming for one endpoint.
* Includes infinite retry mechanism and stale connection detection.
*
* @module lib/client
* @author StalkChain Team
* @version 1.1.1
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GrpcClient = void 0;
const events_1 = require("events");
const yellowstone_grpc_1 = __importDefault(require("@triton-one/yellowstone-grpc"));
const constants_1 = require("../constants");
/**
* Simple wrapper for a single gRPC client with infinite retry and stale detection
*/
class GrpcClient extends events_1.EventEmitter {
constructor(endpoint, options) {
super();
this.connected = false;
this.stream = null;
this.retryAttempts = 0;
this.retryTimeout = null;
this.lastMessageTimestamp = 0;
this.currentSubscription = null;
this.endpoint = endpoint;
this.client = new yellowstone_grpc_1.default(endpoint.endpoint, endpoint.token, {});
this.lastMessageTimestamp = Date.now(); // Initialize to current time
this.config = {
staleTimeoutMs: options?.staleTimeoutMs ?? constants_1.DEFAULT_CONFIG.STALE_CONNECTION_TIMEOUT_MS,
initialRetryDelayMs: options?.initialRetryDelayMs ?? constants_1.DEFAULT_CONFIG.INITIAL_RETRY_DELAY_MS,
maxRetryDelayMs: options?.maxRetryDelayMs ?? constants_1.DEFAULT_CONFIG.MAX_RETRY_DELAY_MS,
retryBackoffFactor: options?.retryBackoffFactor ?? constants_1.DEFAULT_CONFIG.RETRY_BACKOFF_FACTOR
};
}
/**
* Connect to the gRPC endpoint with infinite retry mechanism
*/
async connect() {
try {
// Create subscription stream
this.stream = await this.client.subscribe();
// Set up stream event handlers
this.stream.on('data', (data) => {
const streamData = {};
if (data.transaction) {
// Only update last message timestamp for actual transactions
this.lastMessageTimestamp = Date.now();
// Pass FULL transaction data to pool (not just extracted fields)
// Pool will handle deduplication and emit complete data to user
streamData.transaction = data.transaction; // Complete gRPC transaction object
streamData.receivedTimestamp = Date.now(); // When client received this data
}
if (data.pong) {
streamData.pong = { id: data.pong.id };
}
this.emit('data', streamData);
});
this.stream.on('error', (error) => {
this.connected = false;
this.emit('error', error);
// Start infinite retry mechanism on stream error
this.scheduleRetry();
});
this.stream.on('end', () => {
this.connected = false;
this.emit('disconnected');
// Start infinite retry mechanism on stream end
this.scheduleRetry();
});
this.connected = true;
this.retryAttempts = 0; // Reset retry counter on successful connection
this.lastMessageTimestamp = Date.now(); // Reset timestamp on successful connection
this.emit('connected');
// Resubscribe if we had a previous subscription
if (this.currentSubscription) {
await this.subscribe(this.currentSubscription);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Schedule infinite retry
this.scheduleRetry();
throw error;
}
}
/**
* Check if connection is stale (no messages received for too long)
*/
isStale() {
if (!this.connected)
return false;
const timeSinceLastMessage = Date.now() - this.lastMessageTimestamp;
return timeSinceLastMessage > this.config.staleTimeoutMs;
}
/**
* Clean up existing connection resources properly
*
* This method safely closes streams and clients while preserving the
* subscription state, which should persist across reconnections.
*/
cleanupConnection() {
// Close existing stream if it exists
if (this.stream) {
try {
// Add error handler to prevent unhandled error events during cleanup
this.stream.on('error', () => { }); // Ignore errors during cleanup
// Remove event listeners to prevent duplicate handlers
this.stream.removeAllListeners();
this.stream.end();
this.stream.destroy();
}
catch (error) {
// Cleanup errors are expected and can be ignored
}
this.stream = null;
}
this.connected = false;
}
/**
* Force reconnection for stale connections
*/
async forceReconnect() {
// Emit disconnected event before cleanup if currently connected
if (this.connected) {
this.connected = false;
this.emit('disconnected');
}
// Clean up current connection
this.cleanupConnection();
// Reset retry attempts for immediate reconnection
this.retryAttempts = 0;
// Attempt to reconnect
try {
await this.connect();
}
catch (error) {
// Error already logged in connect method, retry will be scheduled
}
}
/**
* Schedule infinite retry with exponential backoff (500ms to 30s max)
*/
scheduleRetry() {
// Clear any existing retry timeout
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
// Calculate delay with exponential backoff: 500ms * 2^attempt, capped at 30s
const delay = Math.min(this.config.initialRetryDelayMs * Math.pow(this.config.retryBackoffFactor, this.retryAttempts), this.config.maxRetryDelayMs);
this.retryAttempts++;
this.retryTimeout = setTimeout(async () => {
try {
await this.connect();
}
catch (error) {
// Error already logged in connect method, retry will be scheduled again
}
}, delay);
}
/**
* Subscribe using full subscription request object
*/
async subscribe(subscribeRequest) {
if (!this.connected || !this.stream) {
throw new Error('Client not connected');
}
try {
// Store subscription for potential resubscription after reconnection
this.currentSubscription = subscribeRequest;
// Send subscription request using Promise wrapper like working example
await new Promise((resolve, reject) => {
this.stream.write(subscribeRequest, (err) => {
if (err) {
reject(err);
}
else {
resolve(undefined);
}
});
});
}
catch (error) {
throw error;
}
}
/**
* Send ping if enabled for this endpoint
*/
async ping(id) {
if (!this.endpoint.ping || !this.connected || !this.stream) {
return;
}
// Use complete ping request format matching working grpc.service.js
const pingRequest = {
ping: { id },
accounts: {},
accountsDataSlice: [],
transactions: {},
blocks: {},
blocksMeta: {},
slots: {},
transactionsStatus: {},
entry: {},
};
try {
await new Promise((resolve, reject) => {
this.stream.write(pingRequest, (err) => {
if (err) {
reject(err);
}
else {
resolve(undefined);
}
});
});
}
catch (error) {
// Ping errors are handled silently
}
}
/**
* Close the client connection and clear any retry timeouts
*/
async close() {
// Clear retry timeout
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
// Clean up connection
this.cleanupConnection();
// Reset retry counter and subscription
this.retryAttempts = 0;
this.currentSubscription = null;
}
/**
* Check if client is connected
*/
isConnected() {
return this.connected;
}
/**
* Get endpoint info
*/
getEndpoint() {
return this.endpoint;
}
/**
* Get time since last message in milliseconds
*/
getTimeSinceLastMessage() {
return Date.now() - this.lastMessageTimestamp;
}
}
exports.GrpcClient = GrpcClient;
//# sourceMappingURL=client.js.map