forked from snxraven/peardock
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.
324 lines
8.5 KiB
JavaScript
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,
|
|
}
|