@bsv/p2p
Version:
A client for P2P messaging and payments
389 lines • 17.5 kB
JavaScript
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