@bsv/p2p
Version:
A client for P2P messaging and payments
508 lines (426 loc) • 16.9 kB
text/typescript
import { WalletClient, AuthFetch } from '@bsv/sdk'
import { AuthSocketClient } from '@bsv/authsocket-client'
import { Logger } from './Utils/logger.js'
/**
* Defines the structure of a PeerMessage
*/
export interface PeerMessage {
messageId: string
body: string
sender: string
created_at: string
updated_at: string
acknowledged?: boolean
}
/**
* Defines the structure of a message being sent
*/
export interface SendMessageParams {
recipient: string
messageBox: string
body: string | object
messageId?: string
}
/**
* Defines the structure of the response from sendMessage
*/
export interface SendMessageResponse {
status: string
messageId: string
}
/**
* Defines the structure of a request to acknowledge messages
*/
export interface AcknowledgeMessageParams {
messageIds: string[]
}
/**
* Defines the structure of a request to list messages
*/
export interface ListMessagesParams {
messageBox: string
}
/**
* Extendable class for interacting with a MessageBoxServer
*/
export class MessageBoxClient {
private readonly host: string
public readonly authFetch: AuthFetch
private readonly walletClient: WalletClient
private socket?: ReturnType<typeof AuthSocketClient>
private myIdentityKey?: string
constructor({
host = 'https://messagebox.babbage.systems',
walletClient,
enableLogging = false
}: { host?: string, walletClient: WalletClient, enableLogging?: boolean }) {
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
*/
public getJoinedRooms(): Set<string> {
return this.joinedRooms
}
public getIdentityKey(): string {
if (this.myIdentityKey == null) {
throw new Error('[MB CLIENT ERROR] Identity key is not set')
}
return this.myIdentityKey
}
// Add a getter for testing purposes
public get testSocket(): ReturnType<typeof AuthSocketClient> | undefined {
return this.socket
}
/**
* Establish an initial WebSocket connection (optional)
*/
async initializeConnection(): Promise<void> {
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<void>((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
*/
private readonly joinedRooms: Set<string> = new Set()
/**
* Join a WebSocket room before sending messages
*/
async joinRoom(messageBox: string): Promise<void> {
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
}: {
onMessage: (message: PeerMessage) => void
messageBox: string
}): Promise<void> {
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: PeerMessage) => {
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 }: SendMessageParams): Promise<SendMessageResponse> {
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: string
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?: SendMessageResponse): void => {
if (handled) return
handled = true
const socketAny = this.socket as any
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 as any
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: string): Promise<void> {
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(): Promise<void> {
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: SendMessageParams): Promise<SendMessageResponse> {
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: string
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 }: ListMessagesParams): Promise<PeerMessage[]> {
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 }: AcknowledgeMessageParams): Promise<string> {
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
}
}