Files
peardock/client/holesailLocal.js
T
2026-07-11 11:39:59 -04:00

333 lines
8.6 KiB
JavaScript

/**
* Local Holesail client helper for the peardock UI.
*
* Architecture:
* - Electron/Pear main runs `holesailBareControl.cjs` (real `require('holesail')`)
* and writes http://127.0.0.1:<port> + token into app storage.
* - UI talks to that control API over fetch — never loads bare-tcp in the renderer.
* - Node tests: optional in-process `require('holesail')`.
*
* Electron packaged client: NEVER use dynamic `import('fs')` / `import('path')`
* etc. inside the esbuild CJS GUI bundle. Node ESM `import()` in the renderer
* crashes Chromium (exit 5 → grey frozen window). Static imports become
* `require()` in the bundle and work with nodeIntegration.
*/
import fs from 'fs'
import path from 'path'
import net from 'net'
import { createRequire } from 'module'
import { exec } from 'child_process'
/** @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
/**
* Lazy-load npm packages without static-importing natives into every UI load.
* Prefer ambient require (Electron CJS bundle); fall back to createRequire (pear ESM).
* @param {string} id
*/
function nodeRequire(id) {
try {
if (typeof require === 'function') return require(id)
} catch {
// not resolvable
}
try {
const metaUrl =
typeof import.meta !== 'undefined' &&
import.meta &&
typeof import.meta.url === 'string' &&
import.meta.url.startsWith('file:')
? import.meta.url
: null
if (metaUrl) return createRequire(metaUrl)(id)
} catch {
// ignore
}
return createRequire(path.join(process.cwd(), 'package.json'))(id)
}
/**
* @returns {boolean}
*/
function isPearGui() {
// pear run + Electron peardock-client both set Pear.config.storage
return Boolean(globalThis.Pear?.config?.storage)
}
/**
* Resolve control endpoint written by main process.
* @returns {Promise<{ baseUrl: string, token: string } | null>}
*/
async function getControlEndpoint() {
if (controlCache) return controlCache
const storage = globalThis.Pear?.config?.storage
if (!storage) return null
try {
const p = path.join(storage, STATE_FILE)
const raw = fs.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 — 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) {
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 so the main process can start 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 tests only).
* @returns {Promise<Function>}
*/
export async function loadHolesailCtor() {
if (HolesailCtor) return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
const Ctor = nodeRequire('holesail')
const Fn = typeof Ctor === 'function' ? Ctor : Ctor?.default
if (typeof Fn !== 'function') {
throw new Error('holesail package loaded but no constructor export was found')
}
HolesailCtor = Fn
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 (in-process path only).
* @returns {Promise<number>}
*/
export async function findFreePort() {
try {
return await new Promise((resolve, reject) => {
const s = net.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.
* @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 / Electron client → main control HTTP
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
}
try {
const platform = globalThis.process?.platform || ''
const cmd =
platform === 'darwin'
? `open "${href}"`
: platform === 'win32'
? `start "" "${href}"`
: `xdg-open "${href}"`
exec(cmd)
} catch {
// ignore
}
}
export default {
connectLocalHolesail,
disconnectLocalHolesail,
listLocalHolesail,
findFreePort,
loadHolesailCtor,
}