forked from snxraven/peardock
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).
292 lines
7.9 KiB
JavaScript
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 }
|