/** * 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: + 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} */ 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 ` ) } throw err } finally { clearTimeout(timer) } } /** * Load the real holesail constructor in-process (Node / Bare with require.addon). * @returns {Promise} */ 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 ` ) } } /** * Pick a free TCP port on 127.0.0.1 (Node / in-process path only). * @returns {Promise} */ 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, }