UNPKG

@bsv/p2p

Version:

A client for P2P messaging and payments

389 lines 17.5 kB
import { AuthFetch } from '@bsv/sdk'; import { AuthSocketClient } from '@bsv/authsocket-client'; import { Logger } from './Utils/logger.js'; /** * Extendable class for interacting with a MessageBoxServer */ export class MessageBoxClient { host; authFetch; walletClient; socket; myIdentityKey; constructor({ host = 'https://messagebox.babbage.systems', walletClient, enableLogging = false }) { this.host = host; this.walletClient = walletClient; this.authFetch = new AuthFetch(this.walletClient); // Enable or disable logging based on user preference if (enableLogging === true) { Logger.enable(); } } /** * Getter for joinedRooms to use in tests */ getJoinedRooms() { return this.joinedRooms; } getIdentityKey() { if (this.myIdentityKey == null) { throw new Error('[MB CLIENT ERROR] Identity key is not set'); } return this.myIdentityKey; } // Add a getter for testing purposes get testSocket() { return this.socket; } /** * Establish an initial WebSocket connection (optional) */ async initializeConnection() { Logger.log('[MB CLIENT] initializeConnection() STARTED'); // 🔹 Confirm function is called if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { Logger.log('[MB CLIENT] Fetching identity key...'); try { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }); this.myIdentityKey = keyResult.publicKey; Logger.log(`[MB CLIENT] Identity key fetched successfully: ${this.myIdentityKey}`); } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to fetch identity key:', error); throw new Error('Identity key retrieval failed'); } } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { Logger.error('[MB CLIENT ERROR] Identity key is still missing after retrieval!'); throw new Error('Identity key is missing'); } Logger.log('[MB CLIENT] Setting up WebSocket connection...'); if (this.socket == null) { this.socket = AuthSocketClient(this.host, { wallet: this.walletClient }); let identitySent = false; let authenticated = false; this.socket.on('connect', () => { Logger.log('[MB CLIENT] Connected to WebSocket.'); if (!identitySent) { Logger.log('[MB CLIENT] Sending authentication data:', this.myIdentityKey); if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { Logger.error('[MB CLIENT ERROR] Cannot send authentication: Identity key is missing!'); } else { this.socket?.emit('authenticated', { identityKey: this.myIdentityKey }); identitySent = true; } } }); // Listen for authentication success from the server this.socket.on('authenticationSuccess', (data) => { Logger.log(`[MB CLIENT] WebSocket authentication successful: ${JSON.stringify(data)}`); authenticated = true; }); // Handle authentication failures this.socket.on('authenticationFailed', (data) => { Logger.error(`[MB CLIENT ERROR] WebSocket authentication failed: ${JSON.stringify(data)}`); authenticated = false; }); this.socket.on('disconnect', () => { Logger.log('[MB CLIENT] Disconnected from MessageBox server'); this.socket = undefined; identitySent = false; authenticated = false; }); this.socket.on('error', (error) => { Logger.error('[MB CLIENT ERROR] WebSocket error:', error); }); // Wait for authentication confirmation before proceeding await new Promise((resolve, reject) => { setTimeout(() => { if (authenticated) { Logger.log('[MB CLIENT] WebSocket fully authenticated and ready!'); resolve(); } else { reject(new Error('[MB CLIENT ERROR] WebSocket authentication timed out!')); } }, 5000); // Timeout after 5 seconds }); } } /** * Tracks rooms the client has already joined */ joinedRooms = new Set(); /** * Join a WebSocket room before sending messages */ async joinRoom(messageBox) { Logger.log(`[MB CLIENT] Attempting to join WebSocket room: ${messageBox}`); // Ensure WebSocket connection is established first if (this.socket == null) { Logger.log('[MB CLIENT] No WebSocket connection. Initializing...'); await this.initializeConnection(); } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is not defined'); } const roomId = `${this.myIdentityKey ?? ''}-${messageBox}`; if (this.joinedRooms.has(roomId)) { Logger.log(`[MB CLIENT] Already joined WebSocket room: ${roomId}`); return; } try { Logger.log(`[MB CLIENT] Joining WebSocket room: ${roomId}`); await this.socket?.emit('joinRoom', roomId); this.joinedRooms.add(roomId); Logger.log(`[MB CLIENT] Successfully joined room: ${roomId}`); } catch (error) { Logger.error(`[MB CLIENT ERROR] Failed to join WebSocket room: ${roomId}`, error); } } async listenForLiveMessages({ onMessage, messageBox }) { Logger.log(`[MB CLIENT] Setting up listener for WebSocket room: ${messageBox}`); // Ensure WebSocket connection and room join await this.joinRoom(messageBox); // Ensure identity key is available before creating roomId if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is missing. Cannot construct room ID.'); } const roomId = `${this.myIdentityKey}-${messageBox}`; Logger.log(`[MB CLIENT] Listening for messages in room: ${roomId}`); this.socket?.on(`sendMessage-${roomId}`, (message) => { Logger.log(`[MB CLIENT] Received message in room ${roomId}:`, message); onMessage(message); }); } /** * Sends a message over WebSocket if connected; falls back to HTTP otherwise. */ async sendLiveMessage({ recipient, messageBox, body }) { if (recipient == null || recipient.trim() === '') { throw new Error('[MB CLIENT ERROR] Recipient identity key is required'); } if (messageBox == null || messageBox.trim() === '') { throw new Error('[MB CLIENT ERROR] MessageBox is required'); } if (body == null || (typeof body === 'string' && body.trim() === '')) { throw new Error('[MB CLIENT ERROR] Message body cannot be empty'); } // Ensure WebSocket connection and room join before sending await this.joinRoom(messageBox); if (this.socket == null || !this.socket.connected) { Logger.warn('[MB CLIENT WARNING] WebSocket not connected, falling back to HTTP'); return await this.sendMessage({ recipient, messageBox, body }); } // Generate message ID let messageId; try { const hmac = await this.walletClient.createHmac({ data: Array.from(new TextEncoder().encode(JSON.stringify(body))), protocolID: [0, 'messagebox'], keyID: '1', counterparty: recipient }); messageId = Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join(''); } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error); throw new Error('Failed to generate message identifier.'); } const roomId = `${recipient}-${messageBox}`; Logger.log(`[MB CLIENT] Sending WebSocket message to room: ${roomId}`); return await new Promise((resolve, reject) => { const ackEvent = `sendMessageAck-${roomId}`; let handled = false; const ackHandler = (response) => { if (handled) return; handled = true; const socketAny = this.socket; if (typeof socketAny?.off === 'function') { socketAny.off(ackEvent, ackHandler); } Logger.log('[MB CLIENT] Received WebSocket acknowledgment:', response); if (response == null || response.status !== 'success') { Logger.warn('[MB CLIENT] WebSocket message failed, falling back to HTTP'); this.sendMessage({ recipient, messageBox, body }).then(resolve).catch(reject); } else { Logger.log('[MB CLIENT] Message sent successfully via WebSocket:', response); resolve(response); } }; // Register listener before emitting this.socket?.on(ackEvent, ackHandler); // Send the message this.socket?.emit('sendMessage', { roomId, message: { messageId, recipient, body: typeof body === 'string' ? body : JSON.stringify(body) } }); // Timeout fallback after 10 seconds setTimeout(() => { if (!handled) { handled = true; const socketAny = this.socket; if (typeof socketAny?.off === 'function') { socketAny.off(ackEvent, ackHandler); // 🧹 Clean up listener } Logger.warn('[CLIENT] WebSocket acknowledgment timed out, falling back to HTTP'); this.sendMessage({ recipient, messageBox, body }).then(resolve).catch(reject); } }, 10000); }); } /** * Leaves a WebSocket room. */ async leaveRoom(messageBox) { if (this.socket == null) { Logger.warn('[MB CLIENT] Attempted to leave a room but WebSocket is not connected.'); return; } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is not defined'); } const roomId = `${this.myIdentityKey}-${messageBox}`; Logger.log(`[MB CLIENT] Leaving WebSocket room: ${roomId}`); this.socket.emit('leaveRoom', roomId); // Ensure the room is removed from tracking this.joinedRooms.delete(roomId); } /** * Closes WebSocket connection. */ async disconnectWebSocket() { if (this.socket != null) { Logger.log('[MB CLIENT] Closing WebSocket connection...'); this.socket.disconnect(); this.socket = undefined; } else { Logger.log('[MB CLIENT] No active WebSocket connection to close.'); } } /** * Sends a message via HTTP */ async sendMessage(message) { if (message.recipient == null || message.recipient.trim() === '') { throw new Error('You must provide a message recipient!'); } if (message.messageBox == null || message.messageBox.trim() === '') { throw new Error('You must provide a messageBox to send this message into!'); } if (message.body == null || (typeof message.body === 'string' && message.body.trim().length === 0)) { throw new Error('Every message must have a body!'); } // Generate HMAC let messageId; try { const hmac = await this.walletClient.createHmac({ data: Array.from(new TextEncoder().encode(JSON.stringify(message.body))), protocolID: [0, 'messagebox'], keyID: '1', counterparty: message.recipient }); messageId = message.messageId ?? Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join(''); } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error); throw new Error('Failed to generate message identifier.'); } const requestBody = { message: { ...message, messageId, body: JSON.stringify(message.body) } }; try { Logger.log('[MB CLIENT] Sending HTTP request to:', `${this.host}/sendMessage`); Logger.log('[MB CLIENT] Request Body:', JSON.stringify(requestBody, null, 2)); // Ensure the identity key is fetched before sending if (this.myIdentityKey == null || this.myIdentityKey === '') { try { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }); this.myIdentityKey = keyResult.publicKey; Logger.log(`[MB CLIENT] Fetched identity key before sending request: ${this.myIdentityKey}`); } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to fetch identity key:', error); throw new Error('Identity key retrieval failed'); } } // Now create the headers AFTER ensuring identityKey is set const authHeaders = { 'Content-Type': 'application/json' }; Logger.log('[MB CLIENT] Sending Headers:', JSON.stringify(authHeaders, null, 2)); const response = await this.authFetch.fetch(`${this.host}/sendMessage`, { method: 'POST', headers: authHeaders, body: JSON.stringify(requestBody) }); // Debug: Check if bodyUsed before reading Logger.log('[MB CLIENT] Raw Response:', response); Logger.log('[MB CLIENT] Response Body Used?', response.bodyUsed); // Read body only if it's not already consumed if (response.bodyUsed) { throw new Error('[MB CLIENT ERROR] Response body has already been used!'); } const parsedResponse = await response.json(); Logger.log('[MB CLIENT] Raw Response Body:', parsedResponse); if (!response.ok) { Logger.error(`[MB CLIENT ERROR] Failed to send message. HTTP ${response.status}: ${response.statusText}`); throw new Error(`Message sending failed: HTTP ${response.status} - ${response.statusText}`); } if (parsedResponse.status !== 'success') { Logger.error(`[MB CLIENT ERROR] Server returned an error: ${String(parsedResponse.description)}`); throw new Error(parsedResponse.description ?? 'Unknown error from server.'); } Logger.log('[MB CLIENT] Message successfully sent.'); return { ...parsedResponse, messageId }; } catch (error) { Logger.error('[MB CLIENT ERROR] Network or timeout error:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to send message: ${errorMessage}`); } } /** * Lists messages from MessageBoxServer */ async listMessages({ messageBox }) { if (messageBox.trim() === '') { throw new Error('MessageBox cannot be empty'); } const response = await this.authFetch.fetch(`${this.host}/listMessages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageBox }) }); const parsedResponse = await response.json(); if (parsedResponse.status === 'error') { throw new Error(parsedResponse.description); } return parsedResponse.messages; } /** * Acknowledges one or more messages as having been received */ async acknowledgeMessage({ messageIds }) { if (!Array.isArray(messageIds) || messageIds.length === 0) { throw new Error('Message IDs array cannot be empty'); } Logger.log(`[MB CLIENT] Acknowledging messages: ${JSON.stringify(messageIds)}`); const acknowledged = await this.authFetch.fetch(`${this.host}/acknowledgeMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageIds }) }); const parsedAcknowledged = await acknowledged.json(); if (parsedAcknowledged.status === 'error') { throw new Error(parsedAcknowledged.description); } return parsedAcknowledged.status; } } //# sourceMappingURL=MessageBoxClient.js.map