// 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 }