Run real holesail in a Bare worker for Pear UI
CI / test (push) Successful in 9m59s

pear-electron cannot load bare-tcp (require.addon) in the renderer.
Use pear-run + workers/holesail-local.cjs so the actual holesail package
runs under Bare; Node still loads holesail in-process. Add connect
fallback modal with copy URL.
This commit is contained in:
2026-07-10 23:13:30 -04:00
parent 9e39c27b88
commit cc6df798e1
8 changed files with 470 additions and 22 deletions
+210 -15
View File
@@ -1,25 +1,69 @@
/**
* Pear-side Holesail client: bind a local port that proxies to a remote hs:// tunnel.
* Pear-side Holesail client using the **real `holesail` package**.
*
* Avoids Node's `createRequire` (unsupported in Pear's ESM loader).
* Uses dynamic import so CJS packages resolve in both Node and Pear/Bare.
* Why a worker?
* -------------
* `holesail` → `holesail-client` → `@holesail/hyper-cmd-lib-net` → bare-net / bare-tcp.
* bare-tcp ships `.bare` native addons loaded via `require.addon()`. That API exists in
* the Bare runtime (pear-run workers, terminal apps, Node server with CJS require).
* It is **not** available in the pear-electron UI renderer (script-linker require has
* no `.addon` → `require.addon is not a function`).
*
* Holepunch guidance for pear-electron: put Bare-native P2P code in a worker and
* talk over pear-pipe / pear-run (see hello-pear-electron “Workers”).
*
* - **Pear UI**: pear-run `./workers/holesail-local.cjs` (loads real holesail under Bare)
* - **Node** (tests / non-Pear): in-process `require('holesail')` / dynamic import
*/
/** @type {Map<string, { instance: any, info: object, localPort: number }>} */
/** @type {Map<string, { info: object, localPort: number, host: string, url: string }>} */
const localClients = new Map()
let workerPipe = null
let workerReady = null
let workerBuf = ''
/** @type {Map<number, { resolve: Function, reject: Function }>} */
const pending = new Map()
let nextId = 1
let HolesailCtor = null
let holesailLoadError = null
/**
* Load the holesail constructor via ESM dynamic import (CJS interop).
* True when we must use the Bare worker (Pear Electron UI).
* bare-tcp only loads via Bare's require.addon — not in the script-linker renderer.
*/
function needsBareWorker() {
const Pear = globalThis.Pear
if (!Pear) return false
// Electron renderer / Pear GUI: never load bare-tcp in-process
const rt = globalThis.process?.versions
if (rt?.electron) return true
if (typeof Pear.worker?.run === 'function') return true
if (Pear.constructor?.UI) return true
return false
}
/**
* Load the real holesail constructor in-process (Node / Bare with require.addon).
* @returns {Promise<Function>}
*/
async function loadHolesail() {
export async function loadHolesailCtor() {
if (HolesailCtor) return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
// Prefer Node CJS require (matches server + p2ns)
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 {
// Pear ESM: createRequire missing — try dynamic import
}
const mod = await import('holesail')
// CJS default / named interop across Node, Pear, Bare
const Ctor =
(typeof mod === 'function' && mod) ||
mod?.default ||
@@ -27,9 +71,7 @@ async function loadHolesail() {
(mod?.default && mod.default.default) ||
null
if (typeof Ctor !== 'function') {
throw new Error(
'holesail package loaded but no constructor export was found'
)
throw new Error('holesail package loaded but no constructor export was found')
}
HolesailCtor = Ctor
return HolesailCtor
@@ -44,7 +86,121 @@ async function loadHolesail() {
}
/**
* Pick a free TCP port on 127.0.0.1 (falls back to random high port if net unavailable).
* Start (or reuse) the Bare worker that holds the real holesail instance.
*/
async function ensureWorker() {
if (workerPipe && !workerPipe.destroyed) return workerPipe
if (workerReady) return workerReady
workerReady = (async () => {
let run
try {
const mod = await import('pear-run')
run = mod.default || mod
} catch (err) {
throw new Error(
`pear-run unavailable (${err?.message || err}). Cannot start holesail Bare worker.`
)
}
if (typeof run !== 'function') {
throw new Error('pear-run did not export a run() function')
}
const pipe = run('./workers/holesail-local.cjs')
workerPipe = pipe
workerBuf = ''
const onData = (chunk) => {
workerBuf += typeof chunk === 'string' ? chunk : chunk.toString()
let idx
while ((idx = workerBuf.indexOf('\n')) !== -1) {
const line = workerBuf.slice(0, idx).trim()
workerBuf = workerBuf.slice(idx + 1)
if (!line) continue
let msg
try {
msg = JSON.parse(line)
} catch {
continue
}
// id 0 = worker ready banner
if (msg.id === 0) continue
const p = pending.get(msg.id)
if (!p) continue
pending.delete(msg.id)
if (msg.ok) p.resolve(msg.result)
else p.reject(new Error(msg.error || 'Holesail worker error'))
}
}
pipe.on('data', onData)
pipe.on('error', (err) => {
for (const [, p] of pending) p.reject(err)
pending.clear()
workerPipe = null
workerReady = null
})
pipe.on('close', () => {
for (const [, p] of pending) p.reject(new Error('Holesail worker closed'))
pending.clear()
workerPipe = null
workerReady = null
})
pipe.on('crash', ({ exitCode }) => {
const err = new Error(`Holesail worker crashed (exit ${exitCode})`)
for (const [, p] of pending) p.reject(err)
pending.clear()
workerPipe = null
workerReady = null
})
// Wait briefly for ready banner (non-fatal if missing)
await new Promise((r) => setTimeout(r, 50))
return pipe
})()
try {
return await workerReady
} catch (err) {
workerReady = null
throw err
}
}
/**
* @param {string} method
* @param {object} [params]
*/
async function workerRpc(method, params = {}) {
const pipe = await ensureWorker()
const id = nextId++
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error(`Holesail worker timeout (${method})`))
}, 60000)
pending.set(id, {
resolve: (v) => {
clearTimeout(timer)
resolve(v)
},
reject: (e) => {
clearTimeout(timer)
reject(e)
},
})
try {
pipe.write(JSON.stringify({ id, method, params }) + '\n')
} catch (err) {
clearTimeout(timer)
pending.delete(id)
reject(err)
}
})
}
/**
* Pick a free TCP port on 127.0.0.1 (Node / in-process path only).
* @returns {Promise<number>}
*/
export async function findFreePort() {
@@ -62,13 +218,12 @@ export async function findFreePort() {
s.on('error', reject)
})
} catch {
// Pear / restricted environments: best-effort ephemeral range
return 41000 + Math.floor(Math.random() * 10000)
}
}
/**
* Connect to an hs:// URL and listen locally.
* Connect to an hs:// URL and listen locally (real holesail).
* @param {string} urlOrKey
* @param {{ localPort?: number, host?: string, openBrowser?: boolean }} [opts]
*/
@@ -80,7 +235,34 @@ export async function connectLocalHolesail(urlOrKey, opts = {}) {
return localClients.get(url)
}
const Holesail = await loadHolesail()
if (needsBareWorker()) {
const result = await workerRpc('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: 'worker',
}
localClients.set(url, entry)
if (opts.openBrowser !== false) {
tryOpenBrowser(`http://${entry.host}:${entry.localPort}`)
}
return entry
}
// Node / Bare in-process: real holesail package
const Holesail = await loadHolesailCtor()
const localPort = opts.localPort || (await findFreePort())
const host = opts.host || '127.0.0.1'
@@ -99,7 +281,9 @@ export async function connectLocalHolesail(urlOrKey, opts = {}) {
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) {
@@ -116,6 +300,16 @@ export async function disconnectLocalHolesail(urlOrKey) {
const entry = localClients.get(url)
if (!entry) return false
localClients.delete(url)
if (entry.via === 'worker') {
try {
await workerRpc('disconnect', { url })
} catch {
// ignore
}
return true
}
try {
await entry.instance.close()
} catch {
@@ -130,6 +324,7 @@ export function listLocalHolesail() {
localPort: e.localPort,
host: e.host,
state: e.info?.state,
via: e.via,
}))
}
@@ -142,7 +337,6 @@ function tryOpenBrowser(href) {
} catch {
// ignore
}
// Best-effort outside browser (Node only) — never use createRequire
import('child_process')
.then((cp) => {
const exec = cp.exec || cp.default?.exec
@@ -164,4 +358,5 @@ export default {
disconnectLocalHolesail,
listLocalHolesail,
findFreePort,
loadHolesailCtor,
}