OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / cli / host.js

139 lines · javascript ref:
// Reusable bare host: spawn the Mirall worker via bare-sidecar and drive it over the
// worker's NDJSON IPC. This is the seed of the pgh-app `index.js` — the headless
// replacement for Electron main. CJS (repo root is commonjs); the worker subprocess is
// ESM (src/worker/package.json) — it is SPAWNED, never imported, so runtimes don't mix.
//
// bare-sidecar is dual-runtime: under bare it resolves child_process→bare-subprocess and
// spawns its bundled `bare` binary with fd3 as the IPC pipe (= Bare.IPC in the worker),
// exposed here as a Duplex (.write / 'data').  bare-sidecar  ≠  pgh-sidecar (OTA, P4).
const Sidecar = require('bare-sidecar')
const fs = require('bare-fs')
const path = require('bare-path')

const WORKER = path.join(__dirname, '..', 'src', 'worker', 'main.js')

// Spawn one worker and return a driver: request()/on()/waitFor()/until()/close() + ready.
// Same protocol surface as test/helpers/peer.js, minus brittle, running under bare.
function createHost (opts = {}) {
  const {
    storage,
    downloads,
    dhtBootstrap = null,      // local testnet bootstrap; unset → public DHT
    appVersion = '0.0.0-p1',
    flags = {},               // feature-flag overrides (membershipApproval, etc.)
    debug = false,
    readyTimeout = 60000,     // how long to wait for the worker to emit worker-ready before ready rejects
  } = opts

  fs.mkdirSync(storage, { recursive: true })
  fs.mkdirSync(downloads, { recursive: true })

  const sc = new Sidecar(WORKER)
  sc.stdout.on('data', () => {})
  sc.stderr.on('data', (d) => { if (debug) console.error('[worker]', d.toString().trimEnd()) })

  const pending = new Map()
  const listeners = new Map()
  const rawSinks = new Set()   // raw-chunk taps (e.g. the web-UI SSE relay) — bare streams don't
                               // fan out 'data' to a second late listener, so tap inside the one handler
  let id = 0
  let buf = ''
  let alive = true

  function die (reason) {
    if (!alive) return
    alive = false
    const err = new Error('host not available: ' + reason)
    for (const { reject } of pending.values()) reject(err)
    pending.clear()
  }
  sc.on('end', () => die('end'))
  sc.on('close', () => die('close'))
  sc.on('error', () => die('error'))

  sc.on('data', (chunk) => {
    for (const sink of rawSinks) { try { sink(chunk) } catch {} }   // raw tap for the web relay
    buf += chunk.toString()
    let nl
    while ((nl = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
      if (!line.trim()) continue
      let msg; try { msg = JSON.parse(line) } catch { continue }
      if (msg.type === 'response' && pending.has(msg.id)) {
        const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id)
        if (msg.error) reject(Object.assign(new Error(msg.error), { code: msg.code }))
        else resolve(msg.data)
      } else if (typeof msg.type === 'string' && msg.type.startsWith('event:')) {
        for (const cb of listeners.get(msg.type) || []) cb(msg)
      }
      // 'main-request' (chokidar watcher delegation) is ignored here — the worker does its own
      // initialPublishScan/periodicReconcile, so a static folder still publishes without a
      // live watcher. Re-homing the watcher is P2.
    }
  })

  function request (type, args = {}) {
    if (!alive) return Promise.reject(new Error('host not available (worker gone)'))
    return new Promise((resolve, reject) => {
      const mid = 'r' + (id++)
      pending.set(mid, { resolve, reject })
      sc.write(JSON.stringify({ id: mid, type, ...args }) + '\n')
    })
  }
  function on (type, cb) {
    if (!listeners.has(type)) listeners.set(type, [])
    listeners.get(type).push(cb)
  }
  function waitFor (type, pred = () => true, ms = 60000) {
    return new Promise((resolve, reject) => {
      const to = setTimeout(() => reject(new Error('timeout waiting for ' + type)), ms)
      to.unref && to.unref()
      on(type, (m) => { if (pred(m)) { clearTimeout(to); resolve(m) } })
    })
  }
  async function until (type, args, pred, { ms = 60000, every = 300 } = {}) {
    const start = Date.now()
    for (;;) {
      let last = null
      try { last = await request(type, args) } catch {}
      if (last != null && pred(last)) return last
      if (Date.now() - start > ms) throw new Error('until() timed out on ' + type)
      await new Promise((r) => { const t = setTimeout(r, every); t.unref && t.unref() })
    }
  }
  // Resolve only once the worker PROCESS has actually exited — that is what releases the
  // single-writer RocksDB lock on `storage`. The old flat 300ms delay returned while the worker
  // was still up, so a fast restart raced the lock (the "spaces vanished" symptom). Graceful path:
  // ask the worker to shut down (its `shutdown` handler → safeShutdown → Bare.exit). Fallback: if
  // it doesn't exit within timeoutMs, destroy() kills the child. A final cap so close() never hangs.
  function close ({ timeoutMs = 8000 } = {}) {
    return new Promise((resolve) => {
      let done = false
      const finish = () => { if (done) return; done = true; clearTimeout(hard); clearTimeout(cap); resolve() }
      sc.once('exit', finish)                                                       // worker process exited → lock released
      sc.once('close', finish)
      try { request('shutdown').catch(() => {}) } catch {}                          // best-effort graceful drain
      const hard = setTimeout(() => { try { sc.destroy() } catch {} }, timeoutMs)   // force-kill the child
      hard.unref && hard.unref()
      const cap = setTimeout(finish, timeoutMs + 3000)                             // never hang forever
      cap.unref && cap.unref()
    })
  }

  const ready = waitFor('event:worker-ready', () => true, readyTimeout)
  sc.write(JSON.stringify({
    type: 'bootstrap',
    storage,
    downloadFolder: downloads,
    appVersion,
    dev: true,
    verbose: false,
    ...(dhtBootstrap ? { dhtBootstrap } : {}),
    ...flags,
  }) + '\n')

  return { sc, request, on, waitFor, until, close, ready, onRaw: (cb) => { rawSinks.add(cb); return () => rawSinks.delete(cb) } }
}

module.exports = { createHost, WORKER }