This commit is contained in:
Sosthene 2025-08-08 07:31:01 +02:00
commit f8adee1aad
11 changed files with 4652 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

205
README.md Normal file
View File

@ -0,0 +1,205 @@
# SDK Signer Client
A TypeScript client library for connecting to the SDK Signer WebSocket API. This package provides a clean, type-safe interface for communicating with the SDK Signer server.
## Installation
```bash
npm install sdk-signer-client
```
## Quick Start
```typescript
import { SDKSignerClient, MessageType } from 'sdk-signer-client';
// Create client instance
const client = new SDKSignerClient({
url: 'ws://localhost:9090',
apiKey: 'your-api-key'
});
// Connect to server
await client.connect();
// Wait for server to send LISTENING message
const response = await client.waitForListening();
console.log('Server response:', response);
// Disconnect when done
client.disconnect();
```
## Configuration
```typescript
import { ClientConfig } from 'sdk-signer-client';
const config: ClientConfig = {
url: 'ws://localhost:9090', // WebSocket server URL
apiKey: 'your-api-key', // API key for authentication
timeout: 5000, // Connection timeout (ms)
reconnectInterval: 3000, // Reconnection delay (ms)
maxReconnectAttempts: 5 // Max reconnection attempts
};
```
## API Reference
### Constructor
```typescript
new SDKSignerClient(config: ClientConfig)
```
Creates a new client instance with the specified configuration.
### Connection Methods
#### `connect(options?: ConnectionOptions): Promise<void>`
Establishes a WebSocket connection to the server.
```typescript
await client.connect({
timeout: 10000,
headers: { 'Custom-Header': 'value' }
});
```
#### `disconnect(): void`
Closes the WebSocket connection and stops reconnection attempts.
#### `isConnectedToServer(): boolean`
Returns `true` if the client is connected to the server.
### Message Methods
#### `send(message: ClientMessage): void`
Sends a message to the server.
```typescript
client.send({
type: MessageType.LISTENING,
messageId: 'unique-id'
});
```
#### `sendAndWait(message: ClientMessage, expectedType: MessageType, timeout?: number): Promise<ServerResponse>`
Sends a message and waits for a specific response type.
```typescript
const response = await client.sendAndWait(
{ type: MessageType.NOTIFY_UPDATE, processId: '123', stateId: '456' },
MessageType.UPDATE_NOTIFIED,
10000
);
```
### Convenience Methods
#### `listen(): Promise<ServerResponse>`
Sends a listening message to establish connection.
#### `notifyUpdate(processId: string, stateId: string): Promise<ServerResponse>`
Notifies an update for a process.
#### `validateState(processId: string, stateId: string): Promise<ServerResponse>`
Validates a state.
#### `updateProcess(processId: string, stateId: string, data: any): Promise<ServerResponse>`
Updates a process with additional data.
### Event Handling
```typescript
// Connection events
client.on('open', () => console.log('Connected!'));
client.on('close', () => console.log('Disconnected!'));
client.on('error', (error) => console.error('Error:', error));
client.on('reconnect', () => console.log('Reconnected!'));
// Message events
client.on('message', (response) => console.log('Received:', response));
// Remove event handlers
client.off('open');
```
## Message Types
The client supports all message types defined by the server:
- `LISTENING` - Establish connection
- `NOTIFY_UPDATE` - Notify process update
- `VALIDATE_STATE` - Validate process state
- `UPDATE_PROCESS` - Update process data
- `ERROR` - Error responses
- And many more...
## Error Handling
```typescript
try {
await client.connect();
const response = await client.notifyUpdate('process-123', 'state-456');
console.log('Success:', response);
} catch (error) {
console.error('Error:', error.message);
}
// Or use event handlers
client.on('error', (error) => {
console.error('Connection error:', error);
});
```
## Reconnection
The client automatically attempts to reconnect when the connection is lost:
- Exponential backoff with configurable intervals
- Configurable maximum reconnection attempts
- Automatic cleanup on manual disconnect
## Testing
```bash
# Run tests
npm test
# Run tests in watch mode
npm run test:watch
# Build the package
npm run build
```
## Development
```bash
# Install dependencies
npm install
# Start development mode
npm run dev
# Clean build artifacts
npm run clean
```
## TypeScript Support
This package is written in TypeScript and includes full type definitions. All interfaces and types are exported for use in your own TypeScript projects.
## License
MIT

73
examples/basic-usage.ts Normal file
View File

@ -0,0 +1,73 @@
import { SDKSignerClient, MessageType } from '../src/index';
async function basicExample() {
// Use environment variables for configuration, with fallbacks
const serverUrl = process.env.SERVER_URL || 'ws://localhost:9090';
const apiKey = process.env.API_KEY || 'your-api-key-change-this';
console.log(`🔧 Testing against server: ${serverUrl}`);
console.log(`🔑 Using API key: ${apiKey.substring(0, 10)}...`);
console.log('');
// Create client instance
const client = new SDKSignerClient({
url: serverUrl,
apiKey: apiKey
});
// Set up event handlers
client.on('open', () => {
console.log('✅ Connected to SDK Signer server');
});
client.on('close', () => {
console.log('🔌 Disconnected from server');
});
client.on('error', (error: Error) => {
console.error('❌ Error:', error.message);
});
client.on('message', (response: any) => {
console.log('📨 Received:', response);
});
try {
// Connect to server
console.log('🔗 Connecting to server...');
await client.connect();
// Wait for server to send LISTENING message
console.log('👂 Waiting for server LISTENING message...');
const listeningResponse = await client.waitForListening();
console.log('✅ Server listening:', listeningResponse);
// Example: Notify an update
console.log('📢 Notifying update...');
const updateResponse = await client.notifyUpdate('process-123', 'state-456');
console.log('✅ Update response:', updateResponse);
// Example: Validate a state
console.log('✅ Validating state...');
const validateResponse = await client.validateState('process-123', 'state-456');
console.log('✅ Validation response:', validateResponse);
// Wait a bit before disconnecting
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error('❌ Error in example:', error);
process.exit(1); // Exit with error code for CI/CD
} finally {
// Disconnect
console.log('🔌 Disconnecting...');
client.disconnect();
}
}
// Run the example
if (require.main === module) {
basicExample().catch(console.error);
}
export { basicExample };

15
jest.config.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
};

3741
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Executable file
View File

@ -0,0 +1,41 @@
{
"name": "sdk-signer-client",
"version": "1.0.0",
"description": "Client library for SDK Signer WebSocket API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:integration": "ts-node examples/basic-usage.ts",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"websocket",
"client",
"sdk",
"signer",
"api"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.8",
"@types/node": "^20.8.10",
"@types/ws": "^8.5.10",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"dependencies": {
"ws": "^8.14.2"
},
"files": [
"dist/**/*",
"README.md"
]
}

View File

@ -0,0 +1,105 @@
import { SDKSignerClient, MessageType, ClientConfig } from '../index';
describe('SDKSignerClient', () => {
let client: SDKSignerClient;
const mockConfig: ClientConfig = {
url: 'ws://localhost:9090',
apiKey: 'test-api-key'
};
beforeEach(() => {
client = new SDKSignerClient(mockConfig);
});
afterEach(() => {
client.disconnect();
});
describe('constructor', () => {
it('should create client with default config', () => {
const client = new SDKSignerClient({ url: 'ws://test', apiKey: 'key' });
expect(client).toBeInstanceOf(SDKSignerClient);
});
it('should merge custom config with defaults', () => {
const customConfig: ClientConfig = {
url: 'ws://test',
apiKey: 'key',
timeout: 10000,
reconnectInterval: 5000,
maxReconnectAttempts: 3
};
const client = new SDKSignerClient(customConfig);
expect(client).toBeInstanceOf(SDKSignerClient);
});
});
describe('connection state', () => {
it('should start disconnected', () => {
expect(client.isConnectedToServer()).toBe(false);
});
});
describe('message generation', () => {
it('should generate unique message IDs', () => {
const client = new SDKSignerClient(mockConfig);
// Access private method for testing
const generateId = (client as any).generateMessageId.bind(client);
const id1 = generateId();
const id2 = generateId();
expect(id1).not.toBe(id2);
expect(id1).toMatch(/^msg_\d+_[a-z0-9]+$/);
expect(id2).toMatch(/^msg_\d+_[a-z0-9]+$/);
});
});
describe('event handling', () => {
it('should register and call event handlers', () => {
const mockHandler = jest.fn();
client.on('open', mockHandler);
expect(client).toBeDefined();
// Note: We can't easily test WebSocket events without a real server
// This test just ensures the method exists and doesn't throw
});
it('should remove event handlers', () => {
const mockHandler = jest.fn();
client.on('open', mockHandler);
client.off('open');
// Should not throw
expect(client).toBeDefined();
});
});
describe('message creation', () => {
it('should create listening message', () => {
const message = {
type: MessageType.LISTENING,
messageId: 'test-id'
};
expect(message.type).toBe(MessageType.LISTENING);
expect(message.messageId).toBe('test-id');
});
it('should create notify update message', () => {
const message = {
type: MessageType.NOTIFY_UPDATE,
processId: 'process-123',
stateId: 'state-456',
messageId: 'test-id'
};
expect(message.type).toBe(MessageType.NOTIFY_UPDATE);
expect(message.processId).toBe('process-123');
expect(message.stateId).toBe('state-456');
});
});
});

331
src/client.ts Normal file
View File

@ -0,0 +1,331 @@
import WebSocket from 'ws';
import {
ClientConfig,
ClientMessage,
ServerResponse,
ClientEvents,
MessageType,
ConnectionOptions,
MessageHandler,
ConnectionHandler,
ErrorHandler
} from './types';
export class SDKSignerClient {
private ws: WebSocket | null = null;
private config: ClientConfig;
private events: ClientEvents = {};
private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private messageHandlers = new Map<string, MessageHandler>();
private isConnecting = false;
private isConnected = false;
constructor(config: ClientConfig) {
this.config = {
timeout: 5000,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
...config
};
}
/**
* Connect to the SDK Signer server
*/
async connect(options: ConnectionOptions = {}): Promise<void> {
if (this.isConnecting || this.isConnected) {
return;
}
this.isConnecting = true;
return new Promise((resolve, reject) => {
const timeout = options.timeout || this.config.timeout || 5000;
const timeoutId = setTimeout(() => {
this.isConnecting = false;
reject(new Error('Connection timeout'));
}, timeout);
try {
this.ws = new WebSocket(this.config.url, {
headers: options.headers
});
this.ws.on('open', () => {
clearTimeout(timeoutId);
this.isConnecting = false;
this.isConnected = true;
this.reconnectAttempts = 0;
console.log('✅ Connected to SDK Signer server');
if (this.events.open) {
this.events.open();
}
resolve();
});
this.ws.on('message', (data: WebSocket.Data) => {
try {
const response: ServerResponse = JSON.parse(data.toString());
this.handleMessage(response);
} catch (error) {
console.error('Failed to parse message:', error);
}
});
this.ws.on('close', (code: number, reason: Buffer) => {
this.isConnected = false;
console.log(`🔌 Connection closed: ${code} - ${reason.toString()}`);
if (this.events.close) {
this.events.close();
}
// Attempt to reconnect if not manually closed
if (code !== 1000) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error: Error) => {
clearTimeout(timeoutId);
this.isConnecting = false;
console.error('❌ WebSocket error:', error);
if (this.events.error) {
this.events.error(error);
}
reject(error);
});
} catch (error) {
clearTimeout(timeoutId);
this.isConnecting = false;
reject(error);
}
});
}
/**
* Disconnect from the server
*/
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
this.isConnected = false;
this.reconnectAttempts = 0;
}
/**
* Send a message to the server
*/
send(message: ClientMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
// Add API key if not present
if (!message.apiKey && this.config.apiKey) {
message.apiKey = this.config.apiKey;
}
// Generate message ID if not present
if (!message.messageId) {
message.messageId = this.generateMessageId();
}
this.ws.send(JSON.stringify(message));
}
/**
* Send a message and wait for a specific response
*/
async sendAndWait(
message: ClientMessage,
expectedType: MessageType,
timeout: number = 10000
): Promise<ServerResponse> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.messageHandlers.delete(message.messageId!);
reject(new Error(`Timeout waiting for ${expectedType} response to message ${message.type} (${message.messageId})`));
}, timeout);
const messageId = message.messageId || this.generateMessageId();
message.messageId = messageId;
const handler = (response: ServerResponse) => {
if (response.messageId === messageId) {
clearTimeout(timeoutId);
this.messageHandlers.delete(messageId);
if (response.type === MessageType.ERROR) {
const errorMessage = response.error ?
(typeof response.error === 'string' ? response.error : JSON.stringify(response.error)) :
'Unknown server error';
reject(new Error(`Server error for ${message.type}: ${errorMessage}`));
} else if (response.type === expectedType) {
resolve(response);
} else {
reject(new Error(`Unexpected response type: ${response.type}, expected: ${expectedType}`));
}
}
};
this.messageHandlers.set(messageId, handler);
this.send(message);
});
}
/**
* Set event handlers
*/
on(event: keyof ClientEvents, handler: ConnectionHandler | ErrorHandler | MessageHandler): void {
this.events[event] = handler as any;
}
/**
* Remove event handler
*/
off(event: keyof ClientEvents): void {
delete this.events[event];
}
/**
* Check if client is connected
*/
isConnectedToServer(): boolean {
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Wait for server to send LISTENING message (connection establishment)
*/
async waitForListening(): Promise<ServerResponse> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for server LISTENING message'));
}, this.config.timeout || 10000);
const handler = (response: ServerResponse) => {
if (response.type === MessageType.LISTENING) {
clearTimeout(timeout);
this.off('message');
resolve(response);
}
};
this.on('message', handler);
});
}
/**
* Notify an update for a process
*/
async notifyUpdate(processId: string, stateId: string): Promise<ServerResponse> {
const message: ClientMessage = {
type: MessageType.NOTIFY_UPDATE,
processId,
stateId,
messageId: this.generateMessageId()
};
return this.sendAndWait(message, MessageType.UPDATE_NOTIFIED);
}
/**
* Validate a state
*/
async validateState(processId: string, stateId: string): Promise<ServerResponse> {
const message: ClientMessage = {
type: MessageType.VALIDATE_STATE,
processId,
stateId,
messageId: this.generateMessageId()
};
return this.sendAndWait(message, MessageType.STATE_VALIDATED);
}
/**
* Update a process
*/
async updateProcess(processId: string, stateId: string, data: any): Promise<ServerResponse> {
const message: ClientMessage = {
type: MessageType.UPDATE_PROCESS,
processId,
stateId,
...data,
messageId: this.generateMessageId()
};
return this.sendAndWait(message, MessageType.PROCESS_UPDATED);
}
private handleMessage(response: ServerResponse): void {
// Call general message handler
if (this.events.message) {
this.events.message(response);
}
// Call specific message handler if exists
if (response.messageId) {
const handler = this.messageHandlers.get(response.messageId);
if (handler) {
handler(response);
this.messageHandlers.delete(response.messageId);
}
}
// Handle error responses
if (response.type === MessageType.ERROR) {
const errorMessage = response.error ?
(typeof response.error === 'string' ? response.error : JSON.stringify(response.error)) :
'Unknown server error';
console.error('Server error:', errorMessage);
console.error('Full error response:', JSON.stringify(response, null, 2));
if (this.events.error) {
this.events.error(new Error(errorMessage));
}
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= (this.config.maxReconnectAttempts || 5)) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = (this.config.reconnectInterval || 3000) * this.reconnectAttempts;
console.log(`🔄 Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(async () => {
try {
await this.connect();
if (this.events.reconnect) {
this.events.reconnect();
}
} catch (error) {
console.error('Reconnection failed:', error);
this.scheduleReconnect();
}
}, delay);
}
private generateMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}

15
src/index.ts Normal file
View File

@ -0,0 +1,15 @@
// Main entry point for SDK Signer Client
export { SDKSignerClient } from './client';
export * from './types';
// Re-export commonly used types for convenience
export {
MessageType,
ClientConfig,
ClientMessage,
ServerResponse,
ClientEvents,
ProcessData,
UpdateProcessData,
ValidationResult
} from './types';

97
src/types.ts Normal file
View File

@ -0,0 +1,97 @@
// Client-side type definitions for SDK Signer API
export enum MessageType {
// Establish connection and keep alive
LISTENING = 'LISTENING',
REQUEST_LINK = 'REQUEST_LINK',
LINK_ACCEPTED = 'LINK_ACCEPTED',
ERROR = 'ERROR',
VALIDATE_TOKEN = 'VALIDATE_TOKEN',
RENEW_TOKEN = 'RENEW_TOKEN',
// Get various information
GET_PAIRING_ID = 'GET_PAIRING_ID',
GET_PROCESSES = 'GET_PROCESSES',
GET_MY_PROCESSES = 'GET_MY_PROCESSES',
PROCESSES_RETRIEVED = 'PROCESSES_RETRIEVED',
RETRIEVE_DATA = 'RETRIEVE_DATA',
DATA_RETRIEVED = 'DATA_RETRIEVED',
DECODE_PUBLIC_DATA = 'DECODE_PUBLIC_DATA',
PUBLIC_DATA_DECODED = 'PUBLIC_DATA_DECODED',
GET_MEMBER_ADDRESSES = 'GET_MEMBER_ADDRESSES',
MEMBER_ADDRESSES_RETRIEVED = 'MEMBER_ADDRESSES_RETRIEVED',
// Processes
CREATE_PROCESS = 'CREATE_PROCESS',
PROCESS_CREATED = 'PROCESS_CREATED',
UPDATE_PROCESS = 'UPDATE_PROCESS',
PROCESS_UPDATED = 'PROCESS_UPDATED',
NOTIFY_UPDATE = 'NOTIFY_UPDATE',
UPDATE_NOTIFIED = 'UPDATE_NOTIFIED',
VALIDATE_STATE = 'VALIDATE_STATE',
STATE_VALIDATED = 'STATE_VALIDATED',
// Hash and merkle proof
HASH_VALUE = 'HASH_VALUE',
VALUE_HASHED = 'VALUE_HASHED',
GET_MERKLE_PROOF = 'GET_MERKLE_PROOF',
MERKLE_PROOF_RETRIEVED = 'MERKLE_PROOF_RETRIEVED',
VALIDATE_MERKLE_PROOF = 'VALIDATE_MERKLE_PROOF',
MERKLE_PROOF_VALIDATED = 'MERKLE_PROOF_VALIDATED',
// Account management
ADD_DEVICE = 'ADD_DEVICE',
DEVICE_ADDED = 'DEVICE_ADDED',
}
export interface ClientMessage {
type: MessageType;
messageId?: string;
apiKey?: string;
[key: string]: any;
}
export interface ServerResponse {
type: MessageType;
messageId?: string;
error?: string;
[key: string]: any;
}
export interface ClientConfig {
url: string;
apiKey: string;
timeout?: number;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
export interface ConnectionOptions {
timeout?: number;
headers?: Record<string, string>;
}
export type MessageHandler = (response: ServerResponse) => void;
export type ConnectionHandler = () => void;
export type ErrorHandler = (error: Error) => void;
export interface ClientEvents {
open?: ConnectionHandler;
close?: ConnectionHandler;
error?: ErrorHandler;
message?: MessageHandler;
reconnect?: ConnectionHandler;
}
export interface ProcessData {
processId: string;
stateId: string;
[key: string]: any;
}
export interface UpdateProcessData extends ProcessData {
// Add specific fields for process updates
[key: string]: any;
}
export interface ValidationResult {
success: boolean;
message?: string;
data?: any;
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": [
"src/**/*",
"examples/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}