Rename signer-improved to simply signer
All checks were successful
Build and Push to Registry / build-and-push (push) Successful in 51s

This commit is contained in:
Sosthene 2025-09-15 04:34:04 +02:00
parent b04679ba34
commit 7d47eec1f2
4 changed files with 589 additions and 521 deletions

View File

@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { SignerImprovedService } from '../services/signer'; import { SignerService } from '../services/signer';
import { SessionManager } from '../utils/session-manager'; import { SessionManager } from '../utils/session-manager';
import { authTokens } from '../utils/auth-tokens'; import { authTokens } from '../utils/auth-tokens';
import { ProcessInfo, ProcessData, ProcessRoles, EOfficeStatus } from '../types'; import { ProcessInfo, ProcessData, ProcessRoles, EOfficeStatus } from '../types';
@ -32,7 +32,7 @@ export class ProcessController {
const { pairingId } = req.query; const { pairingId } = req.query;
// Execute signer operations with retry logic // Execute signer operations with retry logic
const processResult = await SignerImprovedService.executeWithRetry( const processResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot); return await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot);
}, },
@ -91,7 +91,7 @@ export class ProcessController {
privateFields.splice(privateFields.indexOf('idNot'), 1); privateFields.splice(privateFields.indexOf('idNot'), 1);
// Get pairing ID with retry // Get pairing ID with retry
const pairingResult = await SignerImprovedService.executeWithRetry( const pairingResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.getPairingId(); return await signerClient.getPairingId();
}, },
@ -125,7 +125,7 @@ export class ProcessController {
}; };
// Create process with retry // Create process with retry
const createResult = await SignerImprovedService.executeWithRetry( const createResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.createProcess(processData, privateFields, roles); return await signerClient.createProcess(processData, privateFields, roles);
}, },
@ -155,7 +155,7 @@ export class ProcessController {
} }
// Check if process is committed and handle role updates // Check if process is committed and handle role updates
const processManagementResult = await SignerImprovedService.executeWithRetry( const processManagementResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
const allProcesses = await signerClient.getOwnedProcesses(); const allProcesses = await signerClient.getOwnedProcesses();
@ -202,6 +202,12 @@ export class ProcessController {
const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id;
await signerClient.notifyUpdate(processId, stateId); await signerClient.notifyUpdate(processId, stateId);
await signerClient.validateState(processId, stateId); await signerClient.validateState(processId, stateId);
} else {
Logger.info('PairingId already in owner role', {
requestId,
pairingId: req.query.pairingId,
roles: roles['owner'].members
});
} }
} }
@ -243,7 +249,7 @@ export class ProcessController {
} }
// Get office process with retry // Get office process with retry
const processResult = await SignerImprovedService.executeWithRetry( const processResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot); return await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot);
}, },
@ -264,7 +270,7 @@ export class ProcessController {
}); });
// Get validator ID with retry // Get validator ID with retry
const pairingResult = await SignerImprovedService.executeWithRetry( const pairingResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.getPairingId(); return await signerClient.getPairingId();
}, },
@ -311,6 +317,11 @@ export class ProcessController {
// No need for public fields? // No need for public fields?
const roles: ProcessRoles = { const roles: ProcessRoles = {
collaborator: {
members: [],
validation_rules: [],
storages: []
},
owner: { owner: {
members: [validatorId], members: [validatorId],
validation_rules: [{ validation_rules: [{
@ -328,7 +339,7 @@ export class ProcessController {
}; };
// Create office process with retry // Create office process with retry
const createResult = await SignerImprovedService.executeWithRetry( const createResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.createProcess(processData, privateFields, roles); return await signerClient.createProcess(processData, privateFields, roles);
}, },
@ -341,30 +352,105 @@ export class ProcessController {
} }
Logger.info('Created new office process', { process: createResult.data }); Logger.info('Created new office process', { process: createResult.data });
// Use the idnot number to identify all active members of the office
const officeCollaborators = await IdNotController.getOfficeRattachements(userAuth.idNotUser.office.idNot);
Logger.debug('Office collaborators', { officeCollaborators });
// Logger.info('Created new office process', {
// requestId,
// processId: createResult.data!.processId
// });
process = { process = {
processId: createResult.data!.processCreated.processId || '', processId: createResult.data!.processCreated.processId,
processData: createResult.data!.processData processData: createResult.data!.processData
}; };
} }
Logger.info('Using office process', {
requestId,
processId: process.processId,
processData: process.processData
});
// Check if process is committed and handle role updates
const processManagementResult = await SignerService.executeWithRetry(
async (signerClient) => {
const allProcesses = await signerClient.getOwnedProcesses();
if (allProcesses && process) {
const processStates = allProcesses.processes[process.processId].states;
const isNotCommited = processStates.length === 2
&& processStates[1].commited_in === processStates[0].commited_in;
if (isNotCommited) {
Logger.info('Process not committed, committing it', {
requestId,
processId: process.processId
});
await signerClient.validateState(process.processId, processStates[0].state_id);
}
}
// // Use the idnot number to identify all active members of the office
// const officeCollaborators = await IdNotController.getOfficeRattachements(userAuth.idNotUser.office.idNot);
// Logger.debug('Office collaborators', { officeCollaborators });
// let roles: ProcessRoles;
// if (isNotCommited) {
// const firstState = processStates[0];
// roles = firstState.roles;
// } else {
// const tip = processStates[processStates.length - 1].commited_in;
// const lastState = processStates.findLast((state: any) => state.commited_in !== tip);
// roles = lastState.roles;
// }
// if (!roles) {
// throw new Error('No roles found');
// } else if (!roles['owner'] || !roles['collaborator']) {
// throw new Error('No owner or collaborator role found');
// }
// for (const collaborator of officeCollaborators) {
// if (collaborator.idNot === userAuth.idNotUser.idNot) {
// // We add ourselves regardless of the activity status
// // We should have a collaborator process
// continue;
// }
// if (collaborator.activite === 'En exercice') {
// // TODO we check if the collaborator has a process
// // If not, we create a new process for them
// // if yes, we add the collaborator process in role `collaborator`
// // we also lookup the pairing ids in the collaborator process and put them all in the owner role
// }
// }
// if (!roles['owner'].members.includes(req.query.pairingId as string)) {
// Logger.info('Adding new pairingId to owner role', {
// requestId,
// pairingId: req.query.pairingId,
// processId: process.processId
// });
// roles['owner'].members.push(req.query.pairingId as string);
// const updatedProcessReturn = await signerClient.updateProcess(process.processId, {}, [], roles);
// const processId = updatedProcessReturn.updatedProcess.process_id;
// const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id;
// await signerClient.notifyUpdate(processId, stateId);
// await signerClient.validateState(processId, stateId);
// }
return process;
},
'processManagement',
2
);
console.log('processManagementResult', processManagementResult);
if (!processManagementResult.success) {
throw new ExternalServiceError('Signer', processManagementResult.error?.message || 'Failed to manage office process', requestId);
}
Logger.info('Office process request completed successfully', { Logger.info('Office process request completed successfully', {
requestId, requestId,
processId: process?.processId processId: processManagementResult.data?.processId
}); });
res.json({ res.json({
success: true, success: true,
data: process data: processManagementResult.data
}); });
}); });
@ -401,7 +487,7 @@ export class ProcessController {
Logger.info('Phone number lookup initiated', { requestId, email }); Logger.info('Phone number lookup initiated', { requestId, email });
const phoneResult = await SignerImprovedService.executeWithRetry( const phoneResult = await SignerService.executeWithRetry(
async (signerClient) => { async (signerClient) => {
return await signerClient.getPhoneNumberForEmail(email); return await signerClient.getPhoneNumberForEmail(email);
}, },
@ -430,7 +516,7 @@ export class ProcessController {
// Health check endpoint for signer service // Health check endpoint for signer service
static getSignerHealth = asyncHandler(async (req: Request, res: Response): Promise<void> => { static getSignerHealth = asyncHandler(async (req: Request, res: Response): Promise<void> => {
const healthStatus = SignerImprovedService.getHealthStatus(); const healthStatus = SignerService.getHealthStatus();
res.json({ res.json({
success: true, success: true,
@ -447,7 +533,7 @@ export class ProcessController {
Logger.info('Force signer reconnection requested', { requestId }); Logger.info('Force signer reconnection requested', { requestId });
const reconnectResult = await SignerImprovedService.forceReconnect(); const reconnectResult = await SignerService.forceReconnect();
if (!reconnectResult.success) { if (!reconnectResult.success) {
throw new ExternalServiceError('Signer', reconnectResult.error?.message || 'Failed to reconnect', requestId); throw new ExternalServiceError('Signer', reconnectResult.error?.message || 'Failed to reconnect', requestId);
@ -458,7 +544,7 @@ export class ProcessController {
res.json({ res.json({
success: true, success: true,
message: 'Signer reconnection initiated', message: 'Signer reconnection initiated',
data: SignerImprovedService.getHealthStatus() data: SignerService.getHealthStatus()
}); });
}); });
} }

View File

@ -2,7 +2,7 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import { config } from './config'; import { config } from './config';
import { routes } from './routes'; import { routes } from './routes';
import { SignerImprovedService } from './services/signer-improved'; import { SignerService } from './services/signer';
import { SessionManager } from './utils/session-manager'; import { SessionManager } from './utils/session-manager';
import { EmailService } from './services/email'; import { EmailService } from './services/email';
import { authTokens } from './utils/auth-tokens'; import { authTokens } from './utils/auth-tokens';
@ -41,7 +41,7 @@ app.use(errorHandler);
// Initialize signer service with enhanced reconnection logic // Initialize signer service with enhanced reconnection logic
(async () => { (async () => {
try { try {
const result = await SignerImprovedService.initialize(); const result = await SignerService.initialize();
if (result.success) { if (result.success) {
Logger.info('Signer service initialized'); Logger.info('Signer service initialized');
} else { } else {
@ -57,7 +57,7 @@ app.use(errorHandler);
})(); })();
// Set up signer connection monitoring // Set up signer connection monitoring
SignerImprovedService.onConnectionChange((connected) => { SignerService.onConnectionChange((connected) => {
if (connected) { if (connected) {
Logger.info('Signer connected'); Logger.info('Signer connected');
} else { } else {
@ -110,13 +110,13 @@ async function startServer(): Promise<void> {
// Graceful shutdown handling // Graceful shutdown handling
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
Logger.info('SIGTERM received, shutting down gracefully'); Logger.info('SIGTERM received, shutting down gracefully');
SignerImprovedService.cleanup(); SignerService.cleanup();
process.exit(0); process.exit(0);
}); });
process.on('SIGINT', () => { process.on('SIGINT', () => {
Logger.info('SIGINT received, shutting down gracefully'); Logger.info('SIGINT received, shutting down gracefully');
SignerImprovedService.cleanup(); SignerService.cleanup();
process.exit(0); process.exit(0);
}); });
@ -126,7 +126,7 @@ process.on('uncaughtException', (error) => {
error: error.message, error: error.message,
stack: error.stack stack: error.stack
}); });
SignerImprovedService.cleanup(); SignerService.cleanup();
process.exit(1); process.exit(1);
}); });

View File

@ -1,482 +0,0 @@
import { SDKSignerClient, ClientConfig } from 'sdk-signer-client';
import { signerConfig } from '../../config/signer';
import { Logger } from '../../utils/logger';
import { Result, ServiceResult } from '../../utils/result';
export enum SignerConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
FAILED = 'failed'
}
export interface SignerHealthCheck {
state: SignerConnectionState;
lastConnected?: number;
lastError?: string;
reconnectAttempts: number;
nextReconnectAt?: number;
}
export class SignerImprovedService {
private static instance: SDKSignerClient | null = null;
private static connectionState: SignerConnectionState = SignerConnectionState.DISCONNECTED;
private static reconnectAttempts: number = 0;
private static lastConnected: number | null = null;
private static lastError: string | null = null;
private static reconnectTimeout: NodeJS.Timeout | null = null;
private static maxReconnectAttempts: number = 10; // More attempts than the SDK default
private static reconnectInterval: number = 5000; // 5 seconds
private static backoffMultiplier: number = 1.5; // Exponential backoff
private static maxReconnectInterval: number = 60000; // Max 60 seconds between attempts
private static healthCheckInterval: NodeJS.Timeout | null = null;
private static connectionCallbacks: Array<(connected: boolean) => void> = [];
/**
* Initialize the signer service with enhanced reconnection logic
*/
static async initialize(): Promise<ServiceResult<void>> {
try {
Logger.info('Initializing Signer service');
// Create client instance
this.instance = new SDKSignerClient(signerConfig);
// Set up event listeners for connection monitoring
this.setupEventListeners();
// Start periodic health checks
this.startHealthChecks();
// Attempt initial connection
const connectResult = await this.connect();
return connectResult;
} catch (error) {
Logger.error('Failed to initialize Signer service', {
error: error instanceof Error ? error.message : 'Unknown error'
});
return Result.fromError(error instanceof Error ? error : new Error('Initialization failed'), 'SIGNER_INIT_ERROR');
}
}
/**
* Get the signer client instance with connection validation
*/
static async getInstance(): Promise<ServiceResult<SDKSignerClient>> {
if (!this.instance) {
const initResult = await this.initialize();
if (!initResult.success) {
return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer service not initialized', initResult.error);
}
}
// Check if we're connected
if (this.connectionState !== SignerConnectionState.CONNECTED) {
// Try to reconnect if not already attempting
if (this.connectionState !== SignerConnectionState.CONNECTING &&
this.connectionState !== SignerConnectionState.RECONNECTING) {
Logger.warn('Signer not connected, attempting reconnection');
const reconnectResult = await this.connect();
if (!reconnectResult.success) {
return Result.failure('SIGNER_CONNECTION_FAILED', 'Failed to connect to signer', reconnectResult.error);
}
} else {
return Result.failure('SIGNER_CONNECTING', 'Signer is currently connecting, please retry');
}
}
return Result.success(this.instance!);
}
/**
* Connect to the signer service with retry logic
*/
private static async connect(): Promise<ServiceResult<void>> {
if (!this.instance) {
return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer client not initialized');
}
this.connectionState = this.reconnectAttempts === 0 ? SignerConnectionState.CONNECTING : SignerConnectionState.RECONNECTING;
try {
// Only log connection attempts if we've been trying for a while
if (this.reconnectAttempts > 2) {
Logger.info('Signer connection attempt', {
attempt: this.reconnectAttempts + 1,
maxAttempts: this.maxReconnectAttempts
});
}
await this.instance.connect();
// Connection successful - state will be updated by the 'open' event handler
return Result.success(undefined);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error';
this.lastError = errorMessage;
// Only log detailed errors after several attempts or if we've reached the limit
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.connectionState = SignerConnectionState.FAILED;
Logger.error('Signer connection failed after all attempts', {
attempts: this.maxReconnectAttempts,
lastError: errorMessage
});
return Result.failure('SIGNER_CONNECTION_FAILED', `Failed to connect after ${this.maxReconnectAttempts} attempts: ${errorMessage}`);
} else if (this.reconnectAttempts > 3) {
Logger.warn('Signer connection failing', {
attempt: this.reconnectAttempts + 1,
error: errorMessage
});
}
// Schedule reconnection with exponential backoff
this.scheduleReconnection();
return Result.failure('SIGNER_CONNECTION_FAILED', errorMessage);
}
}
/**
* Schedule a reconnection attempt with exponential backoff
*/
private static scheduleReconnection(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
// Calculate delay with exponential backoff
const baseDelay = this.reconnectInterval;
const delay = Math.min(
baseDelay * Math.pow(this.backoffMultiplier, this.reconnectAttempts),
this.maxReconnectInterval
);
this.reconnectAttempts++;
// Only log scheduling if this is taking a while
if (this.reconnectAttempts > 2) {
Logger.debug('Next reconnection in', { delayMs: delay });
}
this.reconnectTimeout = setTimeout(async () => {
await this.connect();
}, delay);
}
/**
* Set up event listeners for the signer client
*/
private static setupEventListeners(): void {
if (!this.instance) return;
try {
// Listen for WebSocket close events
this.instance.on('close', () => {
// Don't log here - let handleDisconnection() do the logging
this.handleDisconnection();
});
// Listen for WebSocket error events
this.instance.on('error', (error: Error) => {
// Only log if it's a new error
if (this.lastError !== error.message) {
Logger.error('Signer WebSocket error', { error: error.message });
}
this.handleError(error);
});
// Listen for successful reconnection events
this.instance.on('reconnect', () => {
Logger.info('Signer reconnected via SDK');
this.handleSDKReconnection();
});
// Listen for connection open events
this.instance.on('open', () => {
Logger.info('Signer connected');
this.handleSDKConnection();
});
Logger.debug('Signer event listeners configured');
} catch (error) {
Logger.warn('Could not set up SDK event listeners', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
/**
* Handle disconnection events from WebSocket
*/
private static handleDisconnection(): void {
// Only handle if we were actually connected (avoid duplicate handling)
if (this.connectionState === SignerConnectionState.CONNECTED) {
Logger.warn('Signer disconnected - reconnecting...');
this.connectionState = SignerConnectionState.DISCONNECTED;
this.notifyConnectionCallbacks(false);
// Cancel any pending health checks since we know we're disconnected
this.cancelScheduledOperations();
// Initiate immediate reconnection (but respect ongoing attempts)
if (!this.reconnectTimeout) {
this.scheduleReconnection();
}
}
}
/**
* Handle connection errors from WebSocket
*/
private static handleError(error: Error): void {
this.lastError = error.message;
// Only log detailed error if we were connected (avoid startup error spam)
if (this.connectionState === SignerConnectionState.CONNECTED) {
Logger.error('Signer connection error', { error: error.message });
this.handleDisconnection();
}
}
/**
* Handle successful SDK reconnection
*/
private static handleSDKReconnection(): void {
// The SDK handled the reconnection, update our state
this.connectionState = SignerConnectionState.CONNECTED;
this.lastConnected = Date.now();
this.lastError = null;
this.reconnectAttempts = 0;
// Clear any pending reconnection timeout since SDK handled it
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// Notify callbacks (they will handle the logging)
this.notifyConnectionCallbacks(true);
}
/**
* Handle SDK connection open events
*/
private static handleSDKConnection(): void {
this.connectionState = SignerConnectionState.CONNECTED;
this.lastConnected = Date.now();
this.lastError = null;
this.reconnectAttempts = 0;
// Clear any pending operations
this.cancelScheduledOperations();
// Notify callbacks (they will handle the logging)
this.notifyConnectionCallbacks(true);
}
/**
* Cancel scheduled operations (reconnection, health checks during known disconnection)
*/
private static cancelScheduledOperations(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
/**
* Start periodic health checks
*/
private static startHealthChecks(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck();
}, 30000); // Check every 30 seconds
}
/**
* Perform a health check on the signer connection
*/
private static async performHealthCheck(): Promise<void> {
// Only perform health checks if we think we're connected
if (!this.instance || this.connectionState !== SignerConnectionState.CONNECTED) {
Logger.debug('Skipping health check - not in connected state', {
state: this.connectionState,
hasInstance: !!this.instance
});
return;
}
try {
// Try a simple operation to verify connection is actually working
await this.instance.getPairingId();
Logger.debug('Signer health check passed');
} catch (error) {
Logger.warn('Signer health check failed - connection may be stale', {
error: error instanceof Error ? error.message : 'Unknown error'
});
// Health check failed, but we haven't received a close/error event
// This indicates a stale connection - treat as disconnection
this.handleDisconnection();
}
}
/**
* Execute a signer operation with automatic retry on connection failure
*/
static async executeWithRetry<T>(
operation: (client: SDKSignerClient) => Promise<T>,
operationName: string,
maxRetries: number = 3
): Promise<ServiceResult<T>> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const clientResult = await this.getInstance();
if (!clientResult.success) {
lastError = new Error(clientResult.error?.message || 'Failed to get signer instance');
if (attempt < maxRetries) {
// Only log operation retries if it's the final attempt or taking too long
if (attempt === maxRetries - 1) {
Logger.warn(`${operationName} final retry attempt`, {
error: lastError.message
});
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
continue;
}
break;
}
const result = await operation(clientResult.data!);
// Only log success if it took multiple attempts
if (attempt > 1) {
Logger.info(`${operationName} succeeded after ${attempt} attempts`);
}
return Result.success(result);
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
// Only log operation failures if it's taking multiple attempts
if (attempt > 1) {
Logger.warn(`${operationName} attempt ${attempt} failed`, {
error: lastError.message
});
}
// If it's a connection error, force reconnection
if (lastError.message.includes('connection') || lastError.message.includes('websocket')) {
this.connectionState = SignerConnectionState.DISCONNECTED;
}
if (attempt < maxRetries) {
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
}
}
}
Logger.error(`${operationName} failed after ${maxRetries} attempts`, {
error: lastError?.message || 'Unknown error'
});
return Result.fromError(lastError || new Error('Operation failed'), 'SIGNER_OPERATION_FAILED');
}
/**
* Get connection health status
*/
static getHealthStatus(): SignerHealthCheck {
return {
state: this.connectionState,
lastConnected: this.lastConnected || undefined,
lastError: this.lastError || undefined,
reconnectAttempts: this.reconnectAttempts,
nextReconnectAt: this.reconnectTimeout ? Date.now() + this.reconnectInterval : undefined
};
}
/**
* Force reconnection (useful for manual recovery)
*/
static async forceReconnect(): Promise<ServiceResult<void>> {
Logger.info('Force reconnection requested');
// Reset state
this.connectionState = SignerConnectionState.DISCONNECTED;
this.reconnectAttempts = 0;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
return await this.connect();
}
/**
* Register a callback for connection state changes
*/
static onConnectionChange(callback: (connected: boolean) => void): () => void {
this.connectionCallbacks.push(callback);
// Return unsubscribe function
return () => {
const index = this.connectionCallbacks.indexOf(callback);
if (index > -1) {
this.connectionCallbacks.splice(index, 1);
}
};
}
/**
* Notify all connection callbacks
*/
private static notifyConnectionCallbacks(connected: boolean): void {
this.connectionCallbacks.forEach(callback => {
try {
callback(connected);
} catch (error) {
Logger.error('Error in connection callback', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
}
/**
* Cleanup resources
*/
static cleanup(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.connectionCallbacks = [];
this.connectionState = SignerConnectionState.DISCONNECTED;
}
}

View File

@ -1,18 +1,482 @@
import { SDKSignerClient } from 'sdk-signer-client'; import { SDKSignerClient, ClientConfig } from 'sdk-signer-client';
import { signerConfig } from '../../config/signer'; import { signerConfig } from '../../config/signer';
import { Logger } from '../../utils/logger';
import { Result, ServiceResult } from '../../utils/result';
export enum SignerConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
FAILED = 'failed'
}
export interface SignerHealthCheck {
state: SignerConnectionState;
lastConnected?: number;
lastError?: string;
reconnectAttempts: number;
nextReconnectAt?: number;
}
export class SignerService { export class SignerService {
private static instance: SDKSignerClient; private static instance: SDKSignerClient | null = null;
private static connectionState: SignerConnectionState = SignerConnectionState.DISCONNECTED;
private static reconnectAttempts: number = 0;
private static lastConnected: number | null = null;
private static lastError: string | null = null;
private static reconnectTimeout: NodeJS.Timeout | null = null;
private static maxReconnectAttempts: number = 10; // More attempts than the SDK default
private static reconnectInterval: number = 5000; // 5 seconds
private static backoffMultiplier: number = 1.5; // Exponential backoff
private static maxReconnectInterval: number = 60000; // Max 60 seconds between attempts
private static healthCheckInterval: NodeJS.Timeout | null = null;
private static connectionCallbacks: Array<(connected: boolean) => void> = [];
static getInstance(): SDKSignerClient { /**
if (!this.instance) { * Initialize the signer service with enhanced reconnection logic
*/
static async initialize(): Promise<ServiceResult<void>> {
try {
Logger.info('Initializing Signer service');
// Create client instance
this.instance = new SDKSignerClient(signerConfig); this.instance = new SDKSignerClient(signerConfig);
// Set up event listeners for connection monitoring
this.setupEventListeners();
// Start periodic health checks
this.startHealthChecks();
// Attempt initial connection
const connectResult = await this.connect();
return connectResult;
} catch (error) {
Logger.error('Failed to initialize Signer service', {
error: error instanceof Error ? error.message : 'Unknown error'
});
return Result.fromError(error instanceof Error ? error : new Error('Initialization failed'), 'SIGNER_INIT_ERROR');
} }
return this.instance;
} }
static async connect(): Promise<void> { /**
const client = this.getInstance(); * Get the signer client instance with connection validation
await client.connect(); */
static async getInstance(): Promise<ServiceResult<SDKSignerClient>> {
if (!this.instance) {
const initResult = await this.initialize();
if (!initResult.success) {
return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer service not initialized', initResult.error);
}
}
// Check if we're connected
if (this.connectionState !== SignerConnectionState.CONNECTED) {
// Try to reconnect if not already attempting
if (this.connectionState !== SignerConnectionState.CONNECTING &&
this.connectionState !== SignerConnectionState.RECONNECTING) {
Logger.warn('Signer not connected, attempting reconnection');
const reconnectResult = await this.connect();
if (!reconnectResult.success) {
return Result.failure('SIGNER_CONNECTION_FAILED', 'Failed to connect to signer', reconnectResult.error);
}
} else {
return Result.failure('SIGNER_CONNECTING', 'Signer is currently connecting, please retry');
}
}
return Result.success(this.instance!);
}
/**
* Connect to the signer service with retry logic
*/
private static async connect(): Promise<ServiceResult<void>> {
if (!this.instance) {
return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer client not initialized');
}
this.connectionState = this.reconnectAttempts === 0 ? SignerConnectionState.CONNECTING : SignerConnectionState.RECONNECTING;
try {
// Only log connection attempts if we've been trying for a while
if (this.reconnectAttempts > 2) {
Logger.info('Signer connection attempt', {
attempt: this.reconnectAttempts + 1,
maxAttempts: this.maxReconnectAttempts
});
}
await this.instance.connect();
// Connection successful - state will be updated by the 'open' event handler
return Result.success(undefined);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error';
this.lastError = errorMessage;
// Only log detailed errors after several attempts or if we've reached the limit
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.connectionState = SignerConnectionState.FAILED;
Logger.error('Signer connection failed after all attempts', {
attempts: this.maxReconnectAttempts,
lastError: errorMessage
});
return Result.failure('SIGNER_CONNECTION_FAILED', `Failed to connect after ${this.maxReconnectAttempts} attempts: ${errorMessage}`);
} else if (this.reconnectAttempts > 3) {
Logger.warn('Signer connection failing', {
attempt: this.reconnectAttempts + 1,
error: errorMessage
});
}
// Schedule reconnection with exponential backoff
this.scheduleReconnection();
return Result.failure('SIGNER_CONNECTION_FAILED', errorMessage);
}
}
/**
* Schedule a reconnection attempt with exponential backoff
*/
private static scheduleReconnection(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
// Calculate delay with exponential backoff
const baseDelay = this.reconnectInterval;
const delay = Math.min(
baseDelay * Math.pow(this.backoffMultiplier, this.reconnectAttempts),
this.maxReconnectInterval
);
this.reconnectAttempts++;
// Only log scheduling if this is taking a while
if (this.reconnectAttempts > 2) {
Logger.debug('Next reconnection in', { delayMs: delay });
}
this.reconnectTimeout = setTimeout(async () => {
await this.connect();
}, delay);
}
/**
* Set up event listeners for the signer client
*/
private static setupEventListeners(): void {
if (!this.instance) return;
try {
// Listen for WebSocket close events
this.instance.on('close', () => {
// Don't log here - let handleDisconnection() do the logging
this.handleDisconnection();
});
// Listen for WebSocket error events
this.instance.on('error', (error: Error) => {
// Only log if it's a new error
if (this.lastError !== error.message) {
Logger.error('Signer WebSocket error', { error: error.message });
}
this.handleError(error);
});
// Listen for successful reconnection events
this.instance.on('reconnect', () => {
Logger.info('Signer reconnected via SDK');
this.handleSDKReconnection();
});
// Listen for connection open events
this.instance.on('open', () => {
Logger.info('Signer connected');
this.handleSDKConnection();
});
Logger.debug('Signer event listeners configured');
} catch (error) {
Logger.warn('Could not set up SDK event listeners', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
/**
* Handle disconnection events from WebSocket
*/
private static handleDisconnection(): void {
// Only handle if we were actually connected (avoid duplicate handling)
if (this.connectionState === SignerConnectionState.CONNECTED) {
Logger.warn('Signer disconnected - reconnecting...');
this.connectionState = SignerConnectionState.DISCONNECTED;
this.notifyConnectionCallbacks(false);
// Cancel any pending health checks since we know we're disconnected
this.cancelScheduledOperations();
// Initiate immediate reconnection (but respect ongoing attempts)
if (!this.reconnectTimeout) {
this.scheduleReconnection();
}
}
}
/**
* Handle connection errors from WebSocket
*/
private static handleError(error: Error): void {
this.lastError = error.message;
// Only log detailed error if we were connected (avoid startup error spam)
if (this.connectionState === SignerConnectionState.CONNECTED) {
Logger.error('Signer connection error', { error: error.message });
this.handleDisconnection();
}
}
/**
* Handle successful SDK reconnection
*/
private static handleSDKReconnection(): void {
// The SDK handled the reconnection, update our state
this.connectionState = SignerConnectionState.CONNECTED;
this.lastConnected = Date.now();
this.lastError = null;
this.reconnectAttempts = 0;
// Clear any pending reconnection timeout since SDK handled it
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// Notify callbacks (they will handle the logging)
this.notifyConnectionCallbacks(true);
}
/**
* Handle SDK connection open events
*/
private static handleSDKConnection(): void {
this.connectionState = SignerConnectionState.CONNECTED;
this.lastConnected = Date.now();
this.lastError = null;
this.reconnectAttempts = 0;
// Clear any pending operations
this.cancelScheduledOperations();
// Notify callbacks (they will handle the logging)
this.notifyConnectionCallbacks(true);
}
/**
* Cancel scheduled operations (reconnection, health checks during known disconnection)
*/
private static cancelScheduledOperations(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
/**
* Start periodic health checks
*/
private static startHealthChecks(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck();
}, 30000); // Check every 30 seconds
}
/**
* Perform a health check on the signer connection
*/
private static async performHealthCheck(): Promise<void> {
// Only perform health checks if we think we're connected
if (!this.instance || this.connectionState !== SignerConnectionState.CONNECTED) {
Logger.debug('Skipping health check - not in connected state', {
state: this.connectionState,
hasInstance: !!this.instance
});
return;
}
try {
// Try a simple operation to verify connection is actually working
await this.instance.getPairingId();
Logger.debug('Signer health check passed');
} catch (error) {
Logger.warn('Signer health check failed - connection may be stale', {
error: error instanceof Error ? error.message : 'Unknown error'
});
// Health check failed, but we haven't received a close/error event
// This indicates a stale connection - treat as disconnection
this.handleDisconnection();
}
}
/**
* Execute a signer operation with automatic retry on connection failure
*/
static async executeWithRetry<T>(
operation: (client: SDKSignerClient) => Promise<T>,
operationName: string,
maxRetries: number = 3
): Promise<ServiceResult<T>> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const clientResult = await this.getInstance();
if (!clientResult.success) {
lastError = new Error(clientResult.error?.message || 'Failed to get signer instance');
if (attempt < maxRetries) {
// Only log operation retries if it's the final attempt or taking too long
if (attempt === maxRetries - 1) {
Logger.warn(`${operationName} final retry attempt`, {
error: lastError.message
});
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
continue;
}
break;
}
const result = await operation(clientResult.data!);
// Only log success if it took multiple attempts
if (attempt > 1) {
Logger.info(`${operationName} succeeded after ${attempt} attempts`);
}
return Result.success(result);
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
// Only log operation failures if it's taking multiple attempts
if (attempt > 1) {
Logger.warn(`${operationName} attempt ${attempt} failed`, {
error: lastError.message
});
}
// If it's a connection error, force reconnection
if (lastError.message.includes('connection') || lastError.message.includes('websocket')) {
this.connectionState = SignerConnectionState.DISCONNECTED;
}
if (attempt < maxRetries) {
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
}
}
}
Logger.error(`${operationName} failed after ${maxRetries} attempts`, {
error: lastError?.message || 'Unknown error'
});
return Result.fromError(lastError || new Error('Operation failed'), 'SIGNER_OPERATION_FAILED');
}
/**
* Get connection health status
*/
static getHealthStatus(): SignerHealthCheck {
return {
state: this.connectionState,
lastConnected: this.lastConnected || undefined,
lastError: this.lastError || undefined,
reconnectAttempts: this.reconnectAttempts,
nextReconnectAt: this.reconnectTimeout ? Date.now() + this.reconnectInterval : undefined
};
}
/**
* Force reconnection (useful for manual recovery)
*/
static async forceReconnect(): Promise<ServiceResult<void>> {
Logger.info('Force reconnection requested');
// Reset state
this.connectionState = SignerConnectionState.DISCONNECTED;
this.reconnectAttempts = 0;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
return await this.connect();
}
/**
* Register a callback for connection state changes
*/
static onConnectionChange(callback: (connected: boolean) => void): () => void {
this.connectionCallbacks.push(callback);
// Return unsubscribe function
return () => {
const index = this.connectionCallbacks.indexOf(callback);
if (index > -1) {
this.connectionCallbacks.splice(index, 1);
}
};
}
/**
* Notify all connection callbacks
*/
private static notifyConnectionCallbacks(connected: boolean): void {
this.connectionCallbacks.forEach(callback => {
try {
callback(connected);
} catch (error) {
Logger.error('Error in connection callback', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
}
/**
* Cleanup resources
*/
static cleanup(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.connectionCallbacks = [];
this.connectionState = SignerConnectionState.DISCONNECTED;
} }
} }