/** * WebSocket-based HTTP tunnel client. * * Connects to the tunnel server via WebSocket, receives HTTP requests, * forwards them to a local port, and sends responses back. * * Also handles WebSocket relay: the server sends ws-upgrade messages, * the client connects to the local WebSocket server using the `ws` library * and relays WebSocket messages bidirectionally through the control channel. */ import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import WebSocket from 'ws'; /** Force HTTP/1.1 for WebSocket connections — HTTP/2 breaks the Upgrade handshake. */ const http1Agent = new https.Agent({ ALPNProtocols: ['http/1.1'] }); /** Minimal logger interface for tunnel client — compatible with pino, console, etc. */ export interface TunnelLogger { info(obj: Record, msg: string): void; warn(obj: Record, msg: string): void; debug(obj: Record, msg: string): void; } const defaultLogger: TunnelLogger = { info(obj, msg) { console.log(msg, obj); }, warn(obj, msg) { console.warn(msg, obj); }, debug(obj, msg) { console.debug(msg, obj); }, }; export interface TunnelOptions { /** Local port to expose */ port: number; /** Tunnel server URL (e.g., "https://vgit-tunnels.volterapp.com") */ host: string; /** Requested tunnel ID (optional — server generates one if omitted) */ tunnelId?: string; /** Shared secret for tunnel server authentication */ secret?: string; /** Whether the tunnel requires JWT auth for incoming requests (default: true) */ authRequired?: boolean; /** Logger instance (defaults to console-based logger) */ logger?: TunnelLogger; } export interface TunnelHandle { /** Public tunnel URL. The hostname is derived from the tunnel ID * (`https://.vgit-tunnels.volterapp.com`), so when a * deterministic tunnelId is passed (mc-up always does: * `mc--` / `mc-`) the URL is STABLE * across container restarts. Random adjective-noun hostnames only * happen when tunnelId is omitted. */ url: string; /** Assigned tunnel ID */ tunnelId: string; /** Close the tunnel connection */ close: () => void; } interface TunnelRequest { type: 'request'; reqId: number; method: string; path: string; headers: Record; body: string | null; } interface TunnelRegistered { type: 'registered'; tunnelId: string; url: string; } interface TunnelWsUpgrade { type: 'ws-upgrade'; connId: number; path: string; headers: Record; } interface TunnelWsMessage { type: 'ws-message'; connId: number; data: string; // base64 binary: boolean; } interface TunnelWsClose { type: 'ws-close'; connId: number; code?: number; reason?: string; } interface TunnelRequestAbort { type: 'request-abort'; reqId: number; } interface TunnelError { type: 'error'; message: string; } type TunnelMessage = | TunnelRequest | TunnelRegistered | TunnelWsUpgrade | TunnelWsMessage | TunnelWsClose | TunnelRequestAbort | TunnelError; // ============================================================================ // Safe WebSocket close helpers // // The `ws` library calls socket.destroy() (TCP RST) in two cases: // 1. .close() in CONNECTING state → abortHandshake() → stream.socket.destroy() // 2. Close timeout (30s after sending close frame) → socket.destroy() // // TCP RST causes ECONNRESET on the local server, crashing it. These helpers // replace .close() with state-aware logic that sends FIN instead of RST. // ============================================================================ /** * Add a no-op error handler to the underlying net.Socket if none exists. * Prevents unhandled ECONNRESET from crashing the tunnel client process. */ function patchSocketErrorHandler(ws: WebSocket): void { const internal = ws as unknown as Record; const socket = internal._socket; if (socket instanceof net.Socket && socket.listenerCount('error') === 0) { socket.on('error', () => { // Intentional no-op — the WebSocket 'error' and 'close' events // handle cleanup. This just prevents the socket error from being // unhandled and crashing the process. }); } } /** * Close an OPEN WebSocket gracefully, replacing the ws library's 30s * destroy timer with one that calls socket.end() (FIN) instead of * socket.destroy() (RST). */ function safeCloseOpen(ws: WebSocket, code: number, reason: string): void { patchSocketErrorHandler(ws); // Send the close frame normally ws.close(code, reason); // Replace the ws library's internal close timer. // ws sets _closeTimer after calling close() — it fires socket.destroy() // after 30s if the peer doesn't respond with a close frame. const internal = ws as unknown as Record; const existingTimer = internal._closeTimer; if (existingTimer) { clearTimeout(existingTimer as ReturnType); internal._closeTimer = null; } // Set our own timer that sends FIN instead of RST const socket = internal._socket; if (socket instanceof net.Socket) { const finTimer = setTimeout(() => { if (!socket.destroyed) { socket.end(); // FIN, not RST } }, 30000); // Don't let this timer keep the process alive finTimer.unref(); internal._closeTimer = finTimer; } } /** * State-aware close that never sends TCP RST to the local server. * * - CONNECTING: Don't call .close() (which triggers abortHandshake → destroy). * Instead, remove relay listeners and let the connection either open * (then close gracefully) or fail naturally. * - OPEN: Use safeCloseOpen() to replace the destroy timer. * - CLOSING: Already closing, just patch the error handler. * - CLOSED: No-op. */ function safeClose(ws: WebSocket, code?: number, reason?: string): void { const closeCode = code ?? 1000; const closeReason = reason ?? ''; switch (ws.readyState) { case WebSocket.CONNECTING: { // Don't call .close() — it would call abortHandshake() → socket.destroy() // Remove message relay listeners so no data flows if the connection opens ws.removeAllListeners('message'); ws.removeAllListeners('open'); // If it eventually opens, close it gracefully then ws.on('open', () => { safeCloseOpen(ws, closeCode, closeReason); }); // If it errors (ECONNREFUSED, etc.), that's fine — natural teardown // Fallback: if neither open nor error fires within 5s (e.g. TCP connects // but HTTP upgrade hangs), terminate to prevent zombie accumulation. const zombieTimer = setTimeout(() => { if (ws.readyState === WebSocket.CONNECTING) { ws.terminate(); } }, 5000); ws.on('open', () => clearTimeout(zombieTimer)); ws.on('error', () => clearTimeout(zombieTimer)); break; } case WebSocket.OPEN: { safeCloseOpen(ws, closeCode, closeReason); break; } case WebSocket.CLOSING: { // Already closing — just make sure the socket error handler is patched patchSocketErrorHandler(ws); break; } case WebSocket.CLOSED: { // Nothing to do break; } } } /** * Create a tunnel to expose a local port via the tunnel server. */ /** * Resolve which loopback address can reach a given port. * Tries 127.0.0.1 (IPv4) first, then ::1 (IPv6). * Returns the working address, or '127.0.0.1' as default. */ async function resolveLocalHost(port: number): Promise { for (const addr of ['127.0.0.1', '::1']) { const ok = await new Promise((resolve) => { const sock = net.connect({ host: addr, port }, () => { sock.destroy(); resolve(true); }); sock.on('error', () => resolve(false)); sock.setTimeout(500, () => { sock.destroy(); resolve(false); }); }); if (ok) return addr; } return '127.0.0.1'; } export function createTunnel({ port, host, tunnelId, secret, authRequired, logger, }: TunnelOptions): Promise { const log = logger ?? defaultLogger; const wsUrl = `${host.replace(/^http/, 'ws')}/ws`; // Resolved loopback address for this port (set before first connection) let localHost = '127.0.0.1'; // Shared state across reconnections let closed = false; let reconnectDelay = 1000; const MAX_RECONNECT_DELAY = 30000; // Application-layer keepalive. The ws library does NOT ping on its own, // and a half-open TCP socket (e.g. peer crashed or an intermediate proxy // silently dropped the flow) will leave ws.readyState === OPEN forever // with no 'close' event ever firing — so the reconnect path never runs. // We send a WebSocket ping every interval; if the peer hasn't responded // with a pong for MAX_MISSED_PONGS consecutive intervals we call // ws.terminate() to force a 'close' event, which triggers the normal // exponential-backoff reconnect below. const KEEPALIVE_INTERVAL_MS = 20_000; // Tolerate transient pong loss before tearing the connection down. An // intermediate proxy (Cloudflare in front of vgit-tunnels) can drop a single // ping/pong control frame on an otherwise-healthy long-lived connection; the // old "terminate after one missed pong" logic turned that into a reconnect // every 20s (the flapping observed in shared.tunnel.err.log). Requiring three // consecutive misses (~60s of true silence) keeps genuine dead-socket // detection while no longer flapping on a single dropped frame. const MAX_MISSED_PONGS = 3; // Set to true on any successful registration. Once true, reconnect attempts that // fail (close without receiving 'registered') will still retry instead of giving up. let everRegistered = false; function connect( onRegistered: (handle: TunnelHandle) => void, onFirstError: ((err: Error) => void) | null ): void { if (closed) return; const ws = new WebSocket(wsUrl, { agent: http1Agent }); let registered = false; // Track local WebSocket connections: connId → WebSocket const localWsConnections = new Map(); // Track active HTTP requests for abort support: reqId → http.ClientRequest const activeRequests = new Map(); // Keepalive: see KEEPALIVE_INTERVAL_MS comment. Armed after registration, // disarmed on close. let missedPongs = 0; let keepaliveTimer: ReturnType | null = null; const stopKeepalive = () => { if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; } }; const startKeepalive = () => { if (keepaliveTimer) return; missedPongs = 0; keepaliveTimer = setInterval(() => { if (missedPongs >= MAX_MISSED_PONGS) { log.warn( { component: 'tunnel_client', action: 'keepalive_timeout', port, tunnelId, missedPongs }, `Tunnel keepalive timeout (no pong in ${(MAX_MISSED_PONGS * KEEPALIVE_INTERVAL_MS) / 1000}s) — terminating for reconnect` ); // Stop the timer before terminating so a wedged socket (where 'close' // is delayed) can't re-fire this branch and log/terminate repeatedly // every interval — the triple "keepalive timeout" lines we saw. stopKeepalive(); try { ws.terminate(); } catch { /* ignore */ } return; } // Assume the pong is missed until one arrives and resets the counter. missedPongs++; try { ws.ping(); } catch { /* ignore */ } }, KEEPALIVE_INTERVAL_MS); }; ws.on('pong', () => { missedPongs = 0; }); const timeout = setTimeout(() => { ws.close(); if (onFirstError) { onFirstError(new Error('Tunnel connection timeout')); onFirstError = null; } }, 10000); ws.on('open', () => { ws.send( JSON.stringify({ type: 'register', tunnelId, secret, replace: true, authRequired: authRequired !== false, }) ); }); ws.on('message', async (data: WebSocket.RawData) => { let msg: TunnelMessage; try { msg = JSON.parse(data.toString()); } catch { return; } if (msg.type === 'error') { clearTimeout(timeout); if (onFirstError) { onFirstError(new Error(`Tunnel server rejected connection: ${msg.message}`)); onFirstError = null; } return; } if (msg.type === 'registered') { clearTimeout(timeout); registered = true; everRegistered = true; reconnectDelay = 1000; // Reset backoff on successful registration startKeepalive(); log.info( { component: 'tunnel_client', action: 'registered', port, tunnelId: msg.tunnelId, url: msg.url, }, `Tunnel registered: localhost:${port} → ${msg.url}` ); onRegistered({ url: msg.url, tunnelId: msg.tunnelId, close: () => { closed = true; stopKeepalive(); for (const [, localWs] of localWsConnections) { safeClose(localWs); } localWsConnections.clear(); ws.close(); }, }); } if (msg.type === 'request') { const localReq = forwardRequest(port, localHost, msg, ws, activeRequests, async (err) => { // On ECONNREFUSED, re-resolve loopback and retry once if (err.code === 'ECONNREFUSED') { const newAddr = await resolveLocalHost(port); if (newAddr !== localHost) { log.info( { component: 'tunnel_client', action: 're_resolved_local_host', port, from: localHost, to: newAddr, }, `Loopback changed from ${localHost} to ${newAddr} for port ${port}` ); localHost = newAddr; const retryReq = forwardRequest(port, localHost, msg, ws, activeRequests); activeRequests.set(msg.reqId, retryReq); return true; // suppressed the error } } return false; }); activeRequests.set(msg.reqId, localReq); } if (msg.type === 'request-abort') { const localReq = activeRequests.get(msg.reqId); if (localReq) { localReq.destroy(); activeRequests.delete(msg.reqId); } } // === WebSocket relay handling (message-level) === if (msg.type === 'ws-upgrade') { // debug, not info: a client whose token expired (or who hits an // endpoint the server hasn't registered) reconnects on its own // backoff. At info this filled the err log with megabytes of // per-attempt churn. Enable TUNNEL_DEBUG to see it. log.debug( { component: 'tunnel_client', action: 'ws_upgrade_received', connId: msg.connId, path: msg.path.split('?', 1)[0], }, `[WS-RELAY] Received ws-upgrade connId=${msg.connId}` ); handleWsUpgrade(port, localHost, ws, msg, localWsConnections, log); } if (msg.type === 'ws-message') { const localWs = localWsConnections.get(msg.connId); if (localWs && localWs.readyState === WebSocket.OPEN) { const buf = Buffer.from(msg.data, 'base64'); localWs.send(buf, { binary: msg.binary }); } } if (msg.type === 'ws-close') { const localWs = localWsConnections.get(msg.connId); if (localWs) { const code = msg.code && msg.code >= 1000 && msg.code <= 4999 && msg.code !== 1005 && msg.code !== 1006 ? msg.code : 1000; safeClose(localWs, code, msg.reason || ''); localWsConnections.delete(msg.connId); } } }); ws.on('error', (err: Error) => { clearTimeout(timeout); if (onFirstError) { onFirstError(err); onFirstError = null; } }); ws.on('close', () => { clearTimeout(timeout); stopKeepalive(); for (const [, localWs] of localWsConnections) { safeClose(localWs); } localWsConnections.clear(); if (closed) return; // Reconnect with exponential backoff + jitter. Jitter spreads // reconnects so that many instances knocked offline together (e.g. the // tunnel server bouncing) don't reconnect in lockstep and stampede it. const jitter = Math.floor(Math.random() * 500); const delay = reconnectDelay + jitter; reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); if (!registered && !everRegistered) { // This connect attempt never registered and we've never had a successful // registration — caller got the error via onFirstError, don't retry. return; } log.info( { component: 'tunnel_client', action: 'reconnecting', port, delay }, `Tunnel disconnected, reconnecting in ${delay}ms` ); setTimeout(() => connect(onRegistered, null), delay); }); } return new Promise((resolve, reject) => { // Resolve which loopback address works before connecting resolveLocalHost(port).then((addr) => { localHost = addr; if (addr !== '127.0.0.1') { log.info( { component: 'tunnel_client', action: 'resolved_local_host', port, address: addr }, `Using ${addr} for localhost:${port}` ); } connect( (handle) => resolve(handle), (err) => reject(err) ); }); }); } /** * Handle a WebSocket upgrade request from the tunnel server. * Uses the `ws` library to connect to the local server (avoids Bun segfault * with raw http.request() upgrade to self). */ function handleWsUpgrade( port: number, localAddr: string, controlWs: WebSocket, msg: TunnelWsUpgrade, localWsConnections: Map, log: TunnelLogger ): void { const wsHost = localAddr.includes(':') ? `[${localAddr}]` : localAddr; const localWsUrl = `ws://${wsHost}:${port}${msg.path}`; // Forward the WebSocket subprotocol from the original browser request. // Vite 6.x requires "vite-hmr" — without it, the upgrade is silently ignored. const rawProtocol = msg.headers['sec-websocket-protocol']; const protocols = rawProtocol ? typeof rawProtocol === 'string' ? rawProtocol.split(',').map((p) => p.trim()) : rawProtocol : []; log.debug( { component: 'tunnel_client', action: 'ws_upgrade_start', connId: msg.connId, port, protocols }, `[WS-RELAY] Connecting WebSocket to localhost:${port}` ); const authorization = msg.headers.authorization; const cookie = msg.headers.cookie; const forwardedHeaders: Record = { host: `localhost:${port}`, origin: `http://localhost:${port}`, }; if (typeof authorization === 'string') forwardedHeaders.authorization = authorization; if (typeof cookie === 'string') forwardedHeaders.cookie = cookie; const localWs = new WebSocket(localWsUrl, protocols, { headers: forwardedHeaders, }); const connectTimeout = setTimeout(() => { log.warn( { component: 'tunnel_client', action: 'ws_upgrade_timeout', connId: msg.connId }, `[WS-RELAY] Connection timeout for connId=${msg.connId}` ); safeClose(localWs); if (controlWs.readyState === WebSocket.OPEN) { controlWs.send( JSON.stringify({ type: 'ws-error', connId: msg.connId, error: 'Connection timeout', }) ); } }, 15000); localWs.on('open', () => { clearTimeout(connectTimeout); localWsConnections.set(msg.connId, localWs); log.debug( { component: 'tunnel_client', action: 'ws_upgrade_success', connId: msg.connId }, `[WS-RELAY] Connected, sending ws-ready for connId=${msg.connId}` ); controlWs.send( JSON.stringify({ type: 'ws-ready', connId: msg.connId, }) ); }); localWs.on('message', (data: WebSocket.RawData, isBinary: boolean) => { if (controlWs.readyState === WebSocket.OPEN) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); controlWs.send( JSON.stringify({ type: 'ws-message', connId: msg.connId, data: buf.toString('base64'), binary: isBinary, }) ); } }); localWs.on('close', (code: number, reason: Buffer) => { log.debug( { component: 'tunnel_client', action: 'ws_relay_local_close', connId: msg.connId, code }, `[WS-RELAY] Local WS closed connId=${msg.connId} code=${code}` ); if (controlWs.readyState === WebSocket.OPEN) { controlWs.send( JSON.stringify({ type: 'ws-close', connId: msg.connId, code, reason: reason?.toString() || '', }) ); } localWsConnections.delete(msg.connId); }); localWs.on('error', (err: Error) => { clearTimeout(connectTimeout); // debug, not warn: the most common cause is the local server rejecting the // upgrade (HTTP 401 → "Expected 101 status code") because the client's // token is missing/expired. That is correct behaviour the server logs on // its own side; surfacing it here at warn turned every such client into // unbounded err-log growth. A genuinely-down local port surfaces via HTTP // 502s and the orphan watchdog. Enable TUNNEL_DEBUG for the full relay log. log.debug( { component: 'tunnel_client', action: 'ws_relay_error', connId: msg.connId, error: err.message, }, `[WS-RELAY] Local WS error connId=${msg.connId}: ${err.message}` ); if (controlWs.readyState === WebSocket.OPEN) { controlWs.send( JSON.stringify({ type: 'ws-error', connId: msg.connId, error: err.message, }) ); } localWsConnections.delete(msg.connId); }); } function send502(ws: WebSocket, reqId: number, message: string): void { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'response', reqId, status: 502, headers: { 'content-type': 'text/plain' }, body: Buffer.from(`Local server error: ${message}`).toString('base64'), }) ); } } /** * Forward a tunneled request to the local server, streaming the response * back over the WebSocket as response-start / response-chunk / response-end. */ function forwardRequest( port: number, localAddr: string, msg: TunnelRequest, ws: WebSocket, activeRequests: Map, onConnRefused?: (err: NodeJS.ErrnoException) => Promise ): http.ClientRequest { const headers: Record = { ...msg.headers }; // Preserve the original Host for apps that build redirect URLs from it (e.g. Clerk/Next.js) // Forward the original as X-Forwarded-Host so the app knows the public hostname if (headers.host) { headers['x-forwarded-host'] = headers.host; } headers['x-forwarded-proto'] = 'https'; // Rewrite host so the target server accepts the request headers.host = `localhost:${port}`; // Rewrite origin and referer to localhost so the local app behaves as if // accessed directly. Prevents CSRF rejections from frameworks that check origin. const localOrigin = `http://localhost:${port}`; if (typeof headers.origin === 'string' && !headers.origin.includes('localhost')) { headers.origin = localOrigin; } if (typeof headers.referer === 'string' && !headers.referer.includes('localhost')) { try { const ref = new URL(headers.referer); headers.referer = `${localOrigin}${ref.pathname}${ref.search}${ref.hash}`; } catch { headers.referer = localOrigin; } } // Remove headers that shouldn't be forwarded delete headers['transfer-encoding']; const req = http.request( { hostname: localAddr, port, path: msg.path, method: msg.method, headers: headers as http.OutgoingHttpHeaders, }, (res) => { // Send headers immediately const responseHeaders: Record = {}; for (const [key, value] of Object.entries(res.headers)) { responseHeaders[key] = value; } if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'response-start', reqId: msg.reqId, status: res.statusCode ?? 200, headers: responseHeaders, }) ); } // Stream body chunks res.on('data', (chunk: Buffer) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'response-chunk', reqId: msg.reqId, data: chunk.toString('base64'), }) ); } }); // Signal completion res.on('end', () => { activeRequests.delete(msg.reqId); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'response-end', reqId: msg.reqId })); } }); } ); req.on('error', (err: NodeJS.ErrnoException) => { activeRequests.delete(msg.reqId); // If ECONNREFUSED and caller wants to retry with re-resolved address, let them if (onConnRefused && err.code === 'ECONNREFUSED') { onConnRefused(err).then((retried) => { if (retried) return; // caller handled it send502(ws, msg.reqId, err.message); }); return; } send502(ws, msg.reqId, err.message); }); if (msg.body) { req.end(Buffer.from(msg.body, 'base64')); } else { req.end(); } return req; } // ============================================================================ // CLI entry point — `bun run tunnel-client.ts --port 3000 [--host URL] [--tunnel-id ID]` // Prints the public URL to stdout, stays alive until SIGTERM/SIGINT. // ============================================================================ if (import.meta.main) { const args = process.argv.slice(2); function flag(name: string): string | undefined { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; } const hasFlag = (name: string) => args.includes(`--${name}`); const port = Number(flag('port')); if (!port) { console.error( 'Usage: bun run tunnel-client.ts --port [--host ] [--tunnel-id ] [--no-auth]' ); process.exit(1); } const host = flag('host') || process.env.TUNNEL_SERVER_URL || 'https://vgit-tunnels.volterapp.com'; const secret = process.env.TUNNEL_SECRET; const tunnelId = flag('tunnel-id'); const noAuth = hasFlag('no-auth'); // CLI logger: send to stderr so only the URL goes to stdout. debug() is // dropped unless TUNNEL_DEBUG is set — the per-connection relay lines are // logged at debug, so this keeps the long-lived *.tunnel.err.log bounded in // steady state while leaving full diagnostics one env var away. const debugEnabled = !!process.env.TUNNEL_DEBUG; const cliLogger: TunnelLogger = { info(_obj, msg) { console.error(msg); }, warn(_obj, msg) { console.error(msg); }, debug(_obj, msg) { if (debugEnabled) console.error(msg); }, }; const opts: TunnelOptions = { port, host, logger: cliLogger }; if (secret) opts.secret = secret; if (tunnelId) opts.tunnelId = tunnelId; if (noAuth) opts.authRequired = false; // IIFE so esbuild's CJS output is happy — top-level await isn't supported // there, but this file is imported by electron/main (CJS) as well as run // via Bun as a CLI. Bun still executes the IIFE normally. void (async () => { const handle = await createTunnel(opts); console.log(handle.url); // Orphan watchdog: tie this tunnel's lifetime to the local server it // fronts. If the port stays unreachable for a sustained window the // container is gone (crash, `docker stop`, Docker Desktop restart — any // path that doesn't run mc-down), so exit rather than keep relaying to a // dead port and holding the vgit tunnel slot. Requiring many consecutive // failures absorbs transient restarts, which take seconds. const WATCHDOG_INTERVAL_MS = 30_000; const WATCHDOG_MAX_DOWN = 10; // 30s × 10 = 5 min of continuous unreachability let downStreak = 0; const probeLocal = () => new Promise((resolve) => { const sock = net.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(true); }); sock.on('error', () => resolve(false)); sock.setTimeout(2000, () => { sock.destroy(); resolve(false); }); }); const watchdog = setInterval(() => { void probeLocal().then((reachable) => { if (reachable) { downStreak = 0; return; } downStreak++; if (downStreak >= WATCHDOG_MAX_DOWN) { console.error( `Local port ${port} unreachable for ${(WATCHDOG_INTERVAL_MS * WATCHDOG_MAX_DOWN) / 1000}s — container gone, exiting tunnel` ); shutdown(); } }); }, WATCHDOG_INTERVAL_MS); watchdog.unref(); const shutdown = () => { clearInterval(keepAlive); clearInterval(watchdog); handle.close(); process.exit(0); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); const keepAlive = setInterval(() => {}, 1000); await new Promise(() => {}); })(); }