forked from snxraven/peardock
333 lines
8.6 KiB
JavaScript
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,
|
|
}
|