Files
peardock/client/holesailBareControl.cjs
T
snxraven f732cab26c Require only bare-* modules under Bare for Holesail control
Stop falling back to Node builtin names (process/crypto/…) which
bare-module cannot resolve. Detect Bare via globalThis.Bare and drop
bare-process (use global pid instead).
2026-07-10 23:22:10 -04:00

292 lines
7.9 KiB
JavaScript

/**
* Bare/Node control plane for local Holesail client sessions.
* Runs in the pear-electron Bare entry (index.js), where require.addon works.
* The UI renderer talks to this over http://127.0.0.1:<port>.
*
* Under Bare: require ONLY bare-* packages (never Node builtin names like
* 'process' / 'crypto' — bare-module has no Node core builtins).
* Under Node: require Node core (bare-* native addons need Bare's require.addon).
*/
'use strict'
const isBare = typeof globalThis.Bare !== 'undefined'
// Strict split — no cross-fallback (Bare cannot load 'process', Node cannot load bare-abort addons).
const http = isBare ? require('bare-http1') : require('http')
const fs = isBare ? require('bare-fs') : require('fs')
const path = isBare ? require('bare-path') : require('path')
const crypto = isBare ? require('bare-crypto') : require('crypto')
const net = isBare ? require('bare-net') : require('net')
const b4a = require('b4a')
const Holesail = require('holesail')
// Avoid bare-process / Node 'process' entirely
function getPid() {
try {
if (typeof globalThis.process?.pid === 'number') return globalThis.process.pid
} catch {
// ignore
}
try {
if (typeof globalThis.Bare?.pid === 'number') return globalThis.Bare.pid
} catch {
// ignore
}
return 0
}
/** @type {Map<string, { instance: any, localPort: number, host: string, url: string, info: object }>} */
const localClients = new Map()
function findFreePort(host = '127.0.0.1') {
return new Promise((resolve, reject) => {
const s = net.createServer()
s.listen(0, host, () => {
const addr = s.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
s.close((err) => (err ? reject(err) : resolve(port)))
})
s.on('error', reject)
})
}
/**
* @param {string} urlOrKey
* @param {{ localPort?: number, host?: string }} [opts]
*/
async function connect(urlOrKey, opts = {}) {
const url = String(urlOrKey || '').trim()
if (!url) throw new Error('Holesail URL required')
if (localClients.has(url)) {
const e = localClients.get(url)
return summarize(e)
}
const host = opts.host || '127.0.0.1'
const localPort = opts.localPort || (await findFreePort(host))
const instance = new Holesail({
client: true,
key: url,
port: localPort,
host,
log: false,
})
const readyMs = Number(opts.readyTimeoutMs) || 45000
await Promise.race([
instance.ready(),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`Holesail client ready() timed out after ${readyMs}ms (DHT / peer discovery)`
)
),
readyMs
)
),
])
const info = instance.info || {}
const entry = {
instance,
info,
localPort: info.port || localPort,
host: info.host || host,
url,
}
if (entry.host === '0.0.0.0' || entry.host === '::') entry.host = '127.0.0.1'
localClients.set(url, entry)
return summarize(entry)
}
function summarize(e) {
return {
url: e.url,
localPort: e.localPort,
host: e.host,
state: e.info?.state || 'listening',
secure: e.info?.secure,
protocol: e.info?.protocol,
}
}
async function disconnect(urlOrKey) {
const url = String(urlOrKey || '').trim()
const entry = localClients.get(url)
if (!entry) return false
localClients.delete(url)
try {
await entry.instance.close()
} catch {
// ignore
}
return true
}
function list() {
return [...localClients.values()].map(summarize)
}
function readJson(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', (c) => chunks.push(typeof c === 'string' ? b4a.from(c) : c))
req.on('end', () => {
if (!chunks.length) return resolve({})
try {
const raw = b4a.toString(chunks.length === 1 ? chunks[0] : b4a.concat(chunks))
resolve(JSON.parse(raw || '{}'))
} catch {
reject(new Error('Invalid JSON body'))
}
})
req.on('error', reject)
})
}
function sendJson(res, status, body) {
const raw = JSON.stringify(body)
res.statusCode = status
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Peardock-Token')
res.end(raw)
}
function makeToken() {
// bare-crypto and Node crypto both expose randomBytes
const bytes = crypto.randomBytes(16)
return b4a.toString(bytes, 'hex')
}
/**
* @param {{
* statePath?: string,
* token?: string,
* host?: string,
* port?: number,
* }} [opts]
*/
async function start(opts = {}) {
const token = opts.token || makeToken()
const host = opts.host || '127.0.0.1'
const pid = getPid()
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.statusCode = 204
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Peardock-Token')
res.end()
return
}
try {
const url = new URL(req.url || '/', `http://${host}`)
const pathname = url.pathname
if (pathname === '/health' && req.method === 'GET') {
sendJson(res, 200, {
ok: true,
service: 'holesail-local',
clients: localClients.size,
bare: isBare,
})
return
}
if (pathname !== '/health') {
const hdr = req.headers['x-peardock-token']
const q = url.searchParams.get('token')
if (hdr !== token && q !== token) {
sendJson(res, 401, { ok: false, error: 'Unauthorized' })
return
}
}
if (pathname === '/ping' && req.method === 'GET') {
sendJson(res, 200, { ok: true, pong: true, clients: localClients.size })
return
}
if (pathname === '/list' && req.method === 'GET') {
sendJson(res, 200, { ok: true, result: list() })
return
}
if (pathname === '/connect' && req.method === 'POST') {
const body = await readJson(req)
const result = await connect(body.url || body.key, {
localPort: body.localPort,
host: body.host,
readyTimeoutMs: body.readyTimeoutMs,
})
sendJson(res, 200, { ok: true, result })
return
}
if (pathname === '/disconnect' && req.method === 'POST') {
const body = await readJson(req)
const result = await disconnect(body.url || body.key)
sendJson(res, 200, { ok: true, result })
return
}
sendJson(res, 404, { ok: false, error: 'Not found' })
} catch (err) {
sendJson(res, 500, { ok: false, error: err?.message || String(err) })
}
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(opts.port || 0, host, resolve)
})
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
const baseUrl = `http://${host}:${port}`
const meta = { port, host, token, baseUrl, pid, bare: isBare }
if (opts.statePath) {
try {
fs.mkdirSync(path.dirname(opts.statePath), { recursive: true })
fs.writeFileSync(opts.statePath, JSON.stringify(meta, null, 2), { mode: 0o600 })
} catch (err) {
console.error('[holesail-control] failed to write state file', err)
}
}
console.log(`[holesail-control] listening on ${baseUrl} (bare=${isBare})`)
return {
...meta,
server,
async close() {
for (const u of [...localClients.keys()]) {
try {
await disconnect(u)
} catch {
// ignore
}
}
await new Promise((resolve) => server.close(() => resolve()))
if (opts.statePath) {
try {
fs.unlinkSync(opts.statePath)
} catch {
// ignore
}
}
},
}
}
module.exports = { start, connect, disconnect, list }