CI / Build & Test (push) Successful in 2m46s
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.
124 lines
5.1 KiB
JavaScript
124 lines
5.1 KiB
JavaScript
/**
|
||
* 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 1–65535' };
|
||
|
||
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 };
|