Files
holesail-browser/native-host/holesail-manager/service-tunnels.js
T
Raven Scott 15caac7032
CI / Build & Test (push) Successful in 2m46s
refactor(native-host): modularize host.js and holesail-manager.js
Split host.js (435 lines) into host/{paths,logger,startup,message-router}.js.
Split holesail-manager.js (698 lines) into holesail-manager/{state,settings,
connections,port-allocator,servers,virtual-hosts,service-tunnels,index}.js.

Top-level host.js and holesail-manager.js become thin shims so index.mjs
requires no changes. Deleted dev scratch file test-cp.mjs.

Updated CI with 12 new node --check lines for all sub-modules.
Updated docs/ARCHITECTURE.md with per-file tables for host/ and
holesail-manager/ sub-modules.

No functionality changed. No new dependencies.
2026-02-28 23:15:08 -05:00

124 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Service tunnel management.
* Connects to a remote Holesail peer and binds it directly to a user-chosen local port.
* Unlike virtual hosts, these are not HTTP-proxied — any TCP client can connect directly.
*/
let Holesail = null;
try { Holesail = require('holesail'); } catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
}
const TUNNEL_HOST = '127.0.0.1';
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
function debugLog(...args) {
if (!DEBUG) return;
const msg = '[holesail-manager:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
if (process.stderr) process.stderr.write(msg + '\n');
}
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt }
let nextServiceTunnelId = 0;
let _saveState = null;
let _emit = null;
let _getReadyTimeoutMs = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) {
_saveState = saveStateFn;
_emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
}
function applyLoaded(loadedNextServiceTunnelId) {
if (loadedNextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loadedNextServiceTunnelId;
}
function generateServiceTunnelId() {
nextServiceTunnelId += 1;
return 'svc-' + nextServiceTunnelId;
}
function readyWithTimeout(hs, label) {
const ms = _getReadyTimeoutMs ? _getReadyTimeoutMs() : 0;
if (!ms || ms <= 0) return hs.ready();
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Tunnel ready timeout after ' + ms + 'ms for ' + label)), ms);
hs.ready().then(() => { clearTimeout(t); resolve(); }, (e) => { clearTimeout(t); reject(e); });
});
}
async function startServiceTunnel(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const { label, hsUrl, localPort } = payload;
if (!label) return { ok: false, error: 'label required' };
if (!hsUrl || !hsUrl.startsWith('hs://')) return { ok: false, error: 'valid hs:// key required' };
if (!localPort || localPort < 1 || localPort > 65535) return { ok: false, error: 'localPort must be 165535' };
const tunnelId = payload.tunnelId || generateServiceTunnelId();
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
const existing = serviceTunnels.get(tunnelId);
if (existing && existing.holesail) { try { await existing.holesail.close(); } catch (_) {} }
try {
const hs = new Holesail({ client: true, key: hsUrl, host: TUNNEL_HOST, port: localPort });
if (typeof hs.on === 'function') {
hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'error';
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
});
hs.on('close', () => {
const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'closed';
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
});
}
await readyWithTimeout(hs, 'svc:' + tunnelId);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() });
if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort });
if (_saveState) _saveState();
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
return { ok: true, tunnelId, label, hsUrl, localPort, state: 'ready' };
} catch (e) {
debugLog('startServiceTunnel: error', e.message);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, state: 'error', error: e.message, createdAt: Date.now() });
if (_saveState) _saveState();
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: e.message });
return { ok: false, error: e.message };
}
}
async function stopServiceTunnel(payload) {
const { tunnelId } = payload;
debugLog('stopServiceTunnel: id=', tunnelId);
const entry = serviceTunnels.get(tunnelId);
if (!entry) return { ok: false, error: 'Service tunnel not found' };
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
serviceTunnels.delete(tunnelId);
if (_saveState) _saveState();
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label: entry.label });
return { ok: true };
}
function getServiceTunnels() {
const list = [];
for (const [id, t] of serviceTunnels) {
list.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort, state: t.state || 'unknown', createdAt: t.createdAt });
}
return list;
}
function getNextServiceTunnelId() { return nextServiceTunnelId; }
async function cleanupServiceTunnels() {
for (const [, t] of serviceTunnels) {
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
}
serviceTunnels.clear();
}
module.exports = { init, applyLoaded, startServiceTunnel, stopServiceTunnel, getServiceTunnels, getNextServiceTunnelId, cleanupServiceTunnels };