Files
peardock/client/holesailLocal.js
T
snxraven 5fbece2bd9 Fix Holesail client connect via Bare main HTTP control
pear-run worker RPC timed out from the Electron UI. Run real holesail
in Bare index.js (holesailBareControl) and have the UI call localhost
HTTP instead of loading bare-tcp in the renderer.
2026-07-10 23:19:31 -04:00

324 lines
8.5 KiB
JavaScript

/**
* Pear-side Holesail client using the **real `holesail` package**.
*
* Architecture (Holepunch pear-electron):
* - Bare main (`index.js`) runs `holesailBareControl.cjs` with real `require('holesail')`
* (require.addon works there). Exposes http://127.0.0.1:<port> + token in app storage.
* - UI renderer never loads bare-tcp; it POSTs to that control API.
* - Node (tests / non-Pear): in-process `require('holesail')`.
*/
/** @type {Map<string, { info: object, localPort: number, host: string, url: string, via?: string }>} */
const localClients = new Map()
const STATE_FILE = 'peardock-holesail-local.json'
let HolesailCtor = null
let holesailLoadError = null
/** @type {{ baseUrl: string, token: string } | null} */
let controlCache = null
/**
* @returns {boolean}
*/
function isPearGui() {
return Boolean(globalThis.Pear?.config)
}
/**
* Resolve Bare control endpoint written by index.js.
* @returns {Promise<{ baseUrl: string, token: string } | null>}
*/
async function getControlEndpoint() {
if (controlCache) return controlCache
const Pear = globalThis.Pear
const storage = Pear?.config?.storage
if (!storage) return null
try {
const fs = await import('fs')
const path = await import('path')
const join = path.join || path.default?.join
const readFileSync = fs.readFileSync || fs.default?.readFileSync
if (typeof join !== 'function' || typeof readFileSync !== 'function') return null
const p = join(storage, STATE_FILE)
const raw = readFileSync(p, 'utf8')
const meta = JSON.parse(raw)
if (!meta?.baseUrl || !meta?.token) return null
controlCache = { baseUrl: meta.baseUrl, token: meta.token }
return controlCache
} catch {
return null
}
}
/**
* Poll storage briefly — Bare main may still be writing the state file.
* @param {number} [ms]
*/
async function waitForControlEndpoint(ms = 8000) {
const start = Date.now()
while (Date.now() - start < ms) {
const ep = await getControlEndpoint()
if (ep) {
// Verify health
try {
const res = await fetch(`${ep.baseUrl}/health`, { method: 'GET' })
if (res.ok) return ep
} catch {
// not up yet
}
controlCache = null
}
await new Promise((r) => setTimeout(r, 200))
}
return getControlEndpoint()
}
/**
* @param {string} method path e.g. /connect
* @param {object} [body]
* @param {number} [timeoutMs]
*/
async function controlRpc(method, body, timeoutMs = 55000) {
const ep = await waitForControlEndpoint()
if (!ep) {
throw new Error(
'Holesail local control service not found. Restart peardock (Bare main starts the control API).'
)
}
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const res = await fetch(`${ep.baseUrl}${method}`, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
'X-Peardock-Token': ep.token,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctrl.signal,
})
const data = await res.json().catch(() => ({}))
if (!res.ok || data.ok === false) {
throw new Error(data.error || `Holesail control HTTP ${res.status}`)
}
return data.result
} catch (err) {
if (err?.name === 'AbortError') {
throw new Error(
`Holesail control timeout (${method}). Is the remote tunnel online? Try: npx holesail <url>`
)
}
throw err
} finally {
clearTimeout(timer)
}
}
/**
* Load the real holesail constructor in-process (Node / Bare with require.addon).
* @returns {Promise<Function>}
*/
export async function loadHolesailCtor() {
if (HolesailCtor) return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
try {
const { createRequire } = await import('module')
const require = createRequire(import.meta.url)
const Ctor = require('holesail')
if (typeof Ctor === 'function') {
HolesailCtor = Ctor
return HolesailCtor
}
} catch {
// createRequire missing
}
const mod = await import('holesail')
const Ctor =
(typeof mod === 'function' && mod) ||
mod?.default ||
mod?.Holesail ||
(mod?.default && mod.default.default) ||
null
if (typeof Ctor !== 'function') {
throw new Error('holesail package loaded but no constructor export was found')
}
HolesailCtor = Ctor
return HolesailCtor
} catch (err) {
holesailLoadError = err
const msg = err?.message || String(err)
throw new Error(
`Could not load holesail in this runtime (${msg}). ` +
`Copy the hs:// URL and run: npx holesail <url>`
)
}
}
/**
* Pick a free TCP port on 127.0.0.1 (Node / in-process path only).
* @returns {Promise<number>}
*/
export async function findFreePort() {
try {
const net = await import('net')
const createServer = net.createServer || net.default?.createServer
if (typeof createServer !== 'function') throw new Error('net.createServer missing')
return await new Promise((resolve, reject) => {
const s = createServer()
s.listen(0, '127.0.0.1', () => {
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)
})
} catch {
return 41000 + Math.floor(Math.random() * 10000)
}
}
/**
* Connect to an hs:// URL and listen locally (real holesail).
* @param {string} urlOrKey
* @param {{ localPort?: number, host?: string, openBrowser?: boolean }} [opts]
*/
export async function connectLocalHolesail(urlOrKey, opts = {}) {
const url = String(urlOrKey || '').trim()
if (!url) throw new Error('Holesail URL required')
if (localClients.has(url)) {
return localClients.get(url)
}
// Pear GUI → Bare main control HTTP (real holesail there)
if (isPearGui()) {
const result = await controlRpc('/connect', {
url,
localPort: opts.localPort,
host: opts.host,
})
const entry = {
instance: {
async close() {
await disconnectLocalHolesail(url)
},
info: result,
},
info: result,
localPort: result.localPort,
host: result.host || '127.0.0.1',
url,
via: 'bare-control',
}
localClients.set(url, entry)
if (opts.openBrowser !== false) {
tryOpenBrowser(`http://${entry.host}:${entry.localPort}`)
}
return entry
}
// Node / tests: real holesail in-process
const Holesail = await loadHolesailCtor()
const localPort = opts.localPort || (await findFreePort())
const host = opts.host || '127.0.0.1'
const instance = new Holesail({
client: true,
key: url,
port: localPort,
host,
log: false,
})
await instance.ready()
const info = instance.info || {}
const entry = {
instance,
info,
localPort: info.port || localPort,
host: info.host || host,
url,
via: 'in-process',
}
if (entry.host === '0.0.0.0' || entry.host === '::') entry.host = '127.0.0.1'
localClients.set(url, entry)
if (opts.openBrowser !== false) {
tryOpenBrowser(`http://${entry.host}:${entry.localPort}`)
}
return entry
}
/**
* @param {string} urlOrKey
*/
export async function disconnectLocalHolesail(urlOrKey) {
const url = String(urlOrKey || '').trim()
const entry = localClients.get(url)
if (!entry) return false
localClients.delete(url)
if (entry.via === 'bare-control') {
try {
await controlRpc('/disconnect', { url }, 15000)
} catch {
// ignore
}
return true
}
try {
await entry.instance.close()
} catch {
// ignore
}
return true
}
export function listLocalHolesail() {
return [...localClients.values()].map((e) => ({
url: e.url,
localPort: e.localPort,
host: e.host,
state: e.info?.state,
via: e.via,
}))
}
function tryOpenBrowser(href) {
try {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(href, '_blank')
return
}
} catch {
// ignore
}
import('child_process')
.then((cp) => {
const exec = cp.exec || cp.default?.exec
if (typeof exec !== 'function') return
const platform = globalThis.process?.platform || ''
const cmd =
platform === 'darwin'
? `open "${href}"`
: platform === 'win32'
? `start "" "${href}"`
: `xdg-open "${href}"`
exec(cmd)
})
.catch(() => {})
}
export default {
connectLocalHolesail,
disconnectLocalHolesail,
listLocalHolesail,
findFreePort,
loadHolesailCtor,
}