OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / examples / hiveworm-app / network.js

176 lines · javascript ref:
// HiveWorm — relay HTTP/WS client
//
// Wraps the four endpoints documented in packages/core/core/relay-node/api.js:
//   GET  /api/hiveworm/biomes
//   GET  /api/hiveworm/<biome>/state
//   GET  /api/hiveworm/<biome>/log?from=<idx>
//   POST /api/hiveworm/<biome>/move
//   WS   /api/hiveworm/<biome>/events    (designed for; falls back to polling)
//
// The WS endpoint is being built by another agent — this client tries it
// first and gracefully degrades to polling /state every config.pollFallbackMs
// if WS isn't available or fails.

import { config } from './config.js'

export class Network {
  constructor ({ relayBase, biome, onEntry, onState, onError } = {}) {
    this.relayBase = (relayBase || config.relayBase).replace(/\/+$/, '')
    this.biome = biome || config.defaultBiome
    this.onEntry = onEntry || (() => {})
    this.onState = onState || (() => {})
    this.onError = onError || (() => {})

    this._ws = null
    this._wsState = 'idle' // 'idle' | 'connecting' | 'open' | 'closed'
    this._pollTimer = null
    this._lastLogIndex = 0
    this._stopped = false
  }

  // ─── HTTP ─────────────────────────────────────────────────

  async listBiomes () {
    const r = await fetch(this.relayBase + '/api/hiveworm/biomes')
    if (!r.ok) throw new Error('listBiomes: ' + r.status)
    return r.json()
  }

  async getState () {
    const r = await fetch(this.relayBase + '/api/hiveworm/' + this.biome + '/state')
    if (!r.ok) throw new Error('getState: HTTP ' + r.status)
    return r.json()
  }

  async getLog (fromIdx = 0) {
    const r = await fetch(this.relayBase + '/api/hiveworm/' + this.biome + '/log?from=' + fromIdx)
    if (!r.ok) throw new Error('getLog: HTTP ' + r.status)
    return r.json()
  }

  /**
   * POST a signed entry to /move. Returns:
   *   { ok: true, index, tick }
   *   { ok: false, reason, layer }   on 422
   * Throws on transport / 500-class errors.
   */
  async submitMove (signedEntry) {
    const url = this.relayBase + '/api/hiveworm/' + this.biome + '/move'
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(signedEntry)
    })
    let body
    try { body = await r.json() } catch (_) { body = null }
    if (r.status === 422) {
      return { ok: false, reason: body?.error || 'rejected', layer: body?.layer || 'unknown' }
    }
    if (!r.ok) {
      throw new Error('submitMove: HTTP ' + r.status + ' ' + (body?.error || ''))
    }
    return { ok: true, index: body.index, tick: body.tick }
  }

  // ─── Live updates: WS first, polling fallback ────────────

  start () {
    this._stopped = false
    this._connectWs()
  }

  stop () {
    this._stopped = true
    if (this._ws) {
      try { this._ws.close() } catch (_) {}
      this._ws = null
    }
    if (this._pollTimer) {
      clearTimeout(this._pollTimer)
      this._pollTimer = null
    }
  }

  _connectWs () {
    if (this._stopped) return
    const wsUrl = this.relayBase.replace(/^http/, 'ws') +
      '/api/hiveworm/' + this.biome + '/events'
    this._wsState = 'connecting'
    let ws
    try {
      ws = new WebSocket(wsUrl)
    } catch (err) {
      this._fallbackToPolling('ws-construct-failed: ' + err.message)
      return
    }
    this._ws = ws

    let opened = false
    ws.addEventListener('open', () => {
      opened = true
      this._wsState = 'open'
    })
    ws.addEventListener('message', (ev) => {
      let msg
      try { msg = JSON.parse(ev.data) } catch (_) { return }
      // Expected envelopes (designed for the other agent's WS):
      //   { type: 'entry', entry: {...} }
      //   { type: 'state', state: {...} }
      // Be generous: accept bare entries / states too.
      if (msg && msg.type === 'entry' && msg.entry) {
        this.onEntry(msg.entry)
      } else if (msg && msg.type === 'state' && msg.state) {
        this.onState(msg.state)
      } else if (msg && msg.schema && typeof msg.schema === 'string') {
        this.onEntry(msg)
      } else if (msg && msg.worms && msg.food) {
        this.onState(msg)
      }
    })
    ws.addEventListener('close', () => {
      this._wsState = 'closed'
      if (this._stopped) return
      if (!opened) {
        // Never connected — fall back to polling
        this._fallbackToPolling('ws-never-opened')
      } else {
        // Was connected; try to reconnect after a short delay
        setTimeout(() => this._connectWs(), config.reconnectDelayMs)
      }
    })
    ws.addEventListener('error', () => {
      // 'error' fires before 'close' — let 'close' handle reconnection.
    })
  }

  _fallbackToPolling (why) {
    if (this._stopped) return
    if (this._pollTimer) return
    // We don't surface this loudly — UI will pick up state via onState.
    console.info('[hiveworm] WS unavailable (' + why + '); polling /state every ' + config.pollFallbackMs + 'ms')
    this._poll()
  }

  async _poll () {
    if (this._stopped) return
    try {
      const log = await this.getLog(this._lastLogIndex)
      if (Array.isArray(log.entries) && log.entries.length > 0) {
        for (const e of log.entries) this.onEntry(e)
        this._lastLogIndex = log.from + log.entries.length
      } else if (this._lastLogIndex === 0) {
        // First poll — no entries yet; pull state instead
        const st = await this.getState()
        this.onState(st)
        // The state alone tells us where we are; tick != log index, but
        // the log getLog will resync naturally next tick.
      }
    } catch (err) {
      this.onError(err)
    } finally {
      if (this._stopped) return
      this._pollTimer = setTimeout(() => this._poll(), config.pollFallbackMs)
    }
  }
}