OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident / lib / device-mesh-manager.js

543 lines · javascript ref:
/**
 * DeviceMeshManager - Identity-based device mesh networking
 * Enables automatic device discovery, status monitoring, and real-time sync
 */

import { randomBytes, createHash } from 'node:crypto'
import { IdentityManager } from './identity-manager.js'

let Hyperswarm, b4a, idEncoding

// Lazy load hyperswarm dependencies with graceful fallback
async function loadNetworkDependencies() {
  if (Hyperswarm && b4a && idEncoding) return true
  
  try {
    const hyperswarmModule = await import('hyperswarm')
    const b4aModule = await import('b4a')
    const idEncodingModule = await import('hypercore-id-encoding')
    
    Hyperswarm = hyperswarmModule.default
    b4a = b4aModule.default
    idEncoding = idEncodingModule.default
    
    return true
  } catch (error) {
    return false
  }
}

/**
 * DeviceMeshManager handles identity-based device mesh networking
 */
export class DeviceMeshManager {
  constructor() {
    this.swarm = null
    this.isJoined = false
    this.meshDiscoveryKey = null
    this.connectedDevices = new Map()
    this.syncInterval = null
    this.statusCheckInterval = null
    this.eventHandlers = new Map()
  }

  /**
   * Derive mesh discovery key from identity
   * All devices with the same identity will use the same discovery key
   * @param {string} identityPublicKey - Hex-encoded identity public key
   * @returns {Buffer} 32-byte mesh discovery key
   */
  deriveMeshDiscoveryKey(identityPublicKey) {
    const seed = `hyperident-mesh-${identityPublicKey}`
    return createHash('sha256').update(seed).digest()
  }

  /**
   * Join the identity mesh network
   * @param {string} passphrase - Passphrase if identity is encrypted
   * @returns {Promise<boolean>} Success status
   */
  async joinMesh(passphrase = null) {
    const networkAvailable = await loadNetworkDependencies()
    if (!networkAvailable) {
      throw new Error('Network dependencies not available. Install with: npm install hyperswarm hypercore-id-encoding b4a')
    }

    if (this.isJoined) {
      throw new Error('Already joined mesh. Leave current mesh first.')
    }

    // Check if identity exists
    if (!IdentityManager.identityExists()) {
      throw new Error('Identity not found. Run "hyperident init" first.')
    }

    // Load identity to get public key
    const identity = await IdentityManager.loadIdentity(passphrase)
    const identityPublicKey = identity.publicKey

    // Derive mesh discovery key from identity
    this.meshDiscoveryKey = this.deriveMeshDiscoveryKey(identityPublicKey)

    // Create hyperswarm instance
    this.swarm = new Hyperswarm()
    this.isJoined = true

    // Handle connections
    this.swarm.on('connection', (connection, info) => {
      this.handleMeshConnection(connection, info)
    })

    // Join the mesh discovery key
    const discovery = this.swarm.join(this.meshDiscoveryKey, { server: true, client: true })
    await discovery.flushed()

    // Start periodic sync and status checks
    this.startPeriodicSync()
    this.startStatusMonitoring()

    return true
  }

  /**
   * Leave the identity mesh network
   * @returns {Promise<void>}
   */
  async leaveMesh() {
    if (!this.isJoined) return

    this.isJoined = false

    // Stop periodic tasks
    this.stopPeriodicSync()
    this.stopStatusMonitoring()

    // Clean up connections
    await this.cleanup()
  }

  /**
   * Handle incoming mesh connection
   * @param {Object} connection - Hyperswarm connection
   * @param {Object} info - Connection info
   */
  async handleMeshConnection(connection, info) {
    const deviceId = connection.remotePublicKey.toString('hex')
    
    // Add to connected devices
    this.connectedDevices.set(deviceId, {
      connection,
      lastSeen: Date.now(),
      status: 'online',
      info
    })

    console.log(`📱 Device connected: ${deviceId.slice(0, 8)}...`)

    // Set up connection handlers
    connection.on('data', (data) => {
      this.handleMeshMessage(deviceId, data)
    })

    connection.on('close', () => {
      this.connectedDevices.delete(deviceId)
      console.log(`📱 Device disconnected: ${deviceId.slice(0, 8)}...`)
      this.emit('device-disconnected', deviceId)
    })

    connection.on('error', (error) => {
      console.error(`Connection error with ${deviceId.slice(0, 8)}...:`, error.message)
      this.connectedDevices.delete(deviceId)
    })

    // Send device info handshake
    await this.sendDeviceInfo(connection)

    this.emit('device-connected', deviceId)
  }

  /**
   * Handle mesh message from another device
   * @param {string} deviceId - Device ID
   * @param {Buffer} data - Message data
   */
  async handleMeshMessage(deviceId, data) {
    try {
      const message = JSON.parse(data.toString('utf8'))
      
      switch (message.type) {
        case 'device-info':
          await this.handleDeviceInfo(deviceId, message.data)
          break
        case 'profile-sync':
          await this.handleProfileSync(deviceId, message.data)
          break
        case 'status-ping':
          await this.handleStatusPing(deviceId, message.data)
          break
        case 'status-pong':
          await this.handleStatusPong(deviceId, message.data)
          break
        default:
          console.warn(`Unknown mesh message type: ${message.type}`)
      }
    } catch (error) {
      console.error(`Error handling mesh message from ${deviceId.slice(0, 8)}...:`, error.message)
    }
  }

  /**
   * Send device info to connected device
   * @param {Object} connection - Connection to send to
   */
  async sendDeviceInfo(connection) {
    try {
      const deviceChain = await IdentityManager.loadDeviceChain()
      const deviceInfo = {
        name: deviceChain.metadata.name,
        publicKey: deviceChain.metadata.publicKey,
        created: deviceChain.metadata.created,
        version: deviceChain.metadata.version
      }

      const message = {
        type: 'device-info',
        data: deviceInfo,
        timestamp: Date.now()
      }

      connection.write(Buffer.from(JSON.stringify(message), 'utf8'))
    } catch (error) {
      console.error('Error sending device info:', error.message)
    }
  }

  /**
   * Handle device info from another device
   * @param {string} deviceId - Device ID
   * @param {Object} deviceInfo - Device information
   */
  async handleDeviceInfo(deviceId, deviceInfo) {
    const device = this.connectedDevices.get(deviceId)
    if (device) {
      device.info = { ...device.info, ...deviceInfo }
      device.lastSeen = Date.now()
      this.emit('device-info-updated', deviceId, deviceInfo)
    }
  }

  /**
   * Sync profiles with all connected devices
   * @returns {Promise<void>}
   */
  async syncProfiles() {
    if (!this.isJoined || this.connectedDevices.size === 0) return

    try {
      const profilesData = await IdentityManager.listProfiles()
      const syncMessage = {
        type: 'profile-sync',
        data: {
          profiles: profilesData.profiles,
          version: profilesData.version,
          lastModified: profilesData.lastModified
        },
        timestamp: Date.now()
      }

      const messageBuffer = Buffer.from(JSON.stringify(syncMessage), 'utf8')

      // Send to all connected devices
      for (const [deviceId, device] of this.connectedDevices) {
        try {
          device.connection.write(messageBuffer)
        } catch (error) {
          console.error(`Error syncing profiles to ${deviceId.slice(0, 8)}...:`, error.message)
        }
      }

      console.log(`📋 Synced profiles to ${this.connectedDevices.size} device(s)`)
    } catch (error) {
      console.error('Error syncing profiles:', error.message)
    }
  }

  /**
   * Handle profile sync from another device
   * @param {string} deviceId - Device ID
   * @param {Object} syncData - Profile sync data
   */
  async handleProfileSync(deviceId, syncData) {
    try {
      // Simple conflict resolution: use most recent timestamp
      const localProfiles = await IdentityManager.listProfiles()
      
      if (syncData.lastModified > localProfiles.lastModified) {
        // Remote profiles are newer, merge them
        const mergedProfiles = { ...localProfiles.profiles, ...syncData.profiles }
        
        // Update local profiles
        const updatedProfilesData = {
          version: Math.max(localProfiles.version, syncData.version),
          profiles: mergedProfiles,
          lastModified: Math.max(localProfiles.lastModified, syncData.lastModified)
        }

        await IdentityManager.writeProfiles(updatedProfilesData)
        console.log(`📋 Merged profiles from device ${deviceId.slice(0, 8)}...`)
        this.emit('profiles-synced', deviceId, syncData)
      }
    } catch (error) {
      console.error(`Error handling profile sync from ${deviceId.slice(0, 8)}...:`, error.message)
    }
  }

  /**
   * Send status ping to all connected devices
   */
  async sendStatusPing() {
    if (!this.isJoined || this.connectedDevices.size === 0) return

    const pingMessage = {
      type: 'status-ping',
      data: { timestamp: Date.now() },
      timestamp: Date.now()
    }

    const messageBuffer = Buffer.from(JSON.stringify(pingMessage), 'utf8')

    for (const [deviceId, device] of this.connectedDevices) {
      try {
        device.connection.write(messageBuffer)
      } catch (error) {
        console.error(`Error sending ping to ${deviceId.slice(0, 8)}...:`, error.message)
      }
    }
  }

  /**
   * Handle status ping from another device
   * @param {string} deviceId - Device ID
   * @param {Object} pingData - Ping data
   */
  async handleStatusPing(deviceId, pingData) {
    const device = this.connectedDevices.get(deviceId)
    if (device) {
      device.lastSeen = Date.now()
      
      // Send pong response
      const pongMessage = {
        type: 'status-pong',
        data: { 
          timestamp: Date.now(),
          pingTimestamp: pingData.timestamp
        },
        timestamp: Date.now()
      }

      try {
        device.connection.write(Buffer.from(JSON.stringify(pongMessage), 'utf8'))
      } catch (error) {
        console.error(`Error sending pong to ${deviceId.slice(0, 8)}...:`, error.message)
      }
    }
  }

  /**
   * Handle status pong from another device
   * @param {string} deviceId - Device ID
   * @param {Object} pongData - Pong data
   */
  async handleStatusPong(deviceId, pongData) {
    const device = this.connectedDevices.get(deviceId)
    if (device) {
      device.lastSeen = Date.now()
      device.latency = Date.now() - pongData.pingTimestamp
      this.emit('device-status-updated', deviceId, device)
    }
  }

  /**
   * Get status of all devices in the mesh
   * @returns {Object} Device status information
   */
  getDeviceStatus() {
    const devices = []
    const now = Date.now()

    for (const [deviceId, device] of this.connectedDevices) {
      devices.push({
        id: deviceId,
        name: device.info?.name || 'Unknown',
        publicKey: device.info?.publicKey || 'Unknown',
        status: device.status,
        lastSeen: device.lastSeen,
        timeSinceLastSeen: now - device.lastSeen,
        latency: device.latency || null,
        connected: device.connection && !device.connection.destroyed
      })
    }

    return {
      meshJoined: this.isJoined,
      totalDevices: devices.length,
      onlineDevices: devices.filter(d => d.connected).length,
      devices
    }
  }

  /**
   * Get mesh status information (alias for getDeviceStatus for CLI compatibility)
   * @returns {Promise<Object>} Mesh status information
   */
  async getMeshStatus() {
    if (!IdentityManager.identityExists()) {
      throw new Error('Identity not found. Run "hyperident init" first.')
    }

    // Load identity and device info
    const identity = await IdentityManager.loadIdentity()
    const deviceChain = await IdentityManager.loadDeviceChain()

    const deviceStatus = this.getDeviceStatus()

    return {
      connected: this.isJoined,
      connectedDevices: deviceStatus.onlineDevices,
      totalDevices: deviceStatus.totalDevices,
      identityPublicKey: identity.publicKey,
      thisDevicePublicKey: deviceChain.metadata.publicKey,
      devices: deviceStatus.devices.map(device => ({
        name: device.name,
        publicKey: device.publicKey,
        status: device.connected ? 'online' : 'offline',
        lastSeen: device.lastSeen
      }))
    }
  }

  /**
   * Start periodic profile synchronization
   */
  startPeriodicSync() {
    if (this.syncInterval) return

    // Sync every 30 seconds
    this.syncInterval = setInterval(() => {
      this.syncProfiles()
    }, 30000)
  }

  /**
   * Stop periodic profile synchronization
   */
  stopPeriodicSync() {
    if (this.syncInterval) {
      clearInterval(this.syncInterval)
      this.syncInterval = null
    }
  }

  /**
   * Start device status monitoring
   */
  startStatusMonitoring() {
    if (this.statusCheckInterval) return

    // Send status ping every 10 seconds
    this.statusCheckInterval = setInterval(() => {
      this.sendStatusPing()
    }, 10000)
  }

  /**
   * Stop device status monitoring
   */
  stopStatusMonitoring() {
    if (this.statusCheckInterval) {
      clearInterval(this.statusCheckInterval)
      this.statusCheckInterval = null
    }
  }

  /**
   * Clean up resources
   */
  async cleanup() {
    // Close all connections
    for (const [deviceId, device] of this.connectedDevices) {
      try {
        device.connection.end()
      } catch (error) {
        // Ignore cleanup errors
      }
    }
    this.connectedDevices.clear()

    // Close swarm
    if (this.swarm) {
      try {
        await this.swarm.destroy()
      } catch (error) {
        // Ignore cleanup errors
      }
      this.swarm = null
    }

    this.meshDiscoveryKey = null
  }

  /**
   * Add event listener
   * @param {string} event - Event name
   * @param {Function} handler - Event handler
   */
  on(event, handler) {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, [])
    }
    this.eventHandlers.get(event).push(handler)
  }

  /**
   * Remove event listener
   * @param {string} event - Event name
   * @param {Function} handler - Event handler
   */
  off(event, handler) {
    const handlers = this.eventHandlers.get(event)
    if (handlers) {
      const index = handlers.indexOf(handler)
      if (index !== -1) {
        handlers.splice(index, 1)
      }
    }
  }

  /**
   * Emit event
   * @param {string} event - Event name
   * @param {...any} args - Event arguments
   */
  emit(event, ...args) {
    const handlers = this.eventHandlers.get(event)
    if (handlers) {
      handlers.forEach(handler => {
        try {
          handler(...args)
        } catch (error) {
          console.error(`Error in event handler for ${event}:`, error.message)
        }
      })
    }
  }

  /**
   * Check if network dependencies are available
   * @returns {Promise<boolean>} Availability status
   */
  static async isNetworkAvailable() {
    return await loadNetworkDependencies()
  }
}