import type { Tunnel } from '@because/sneaker' import { connect } from '@because/sneaker' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { getApp, TOES_DIR, update } from '$apps' import { hostLog } from './tui' const SNEAKER_URL = process.env.SNEAKER_URL ?? 'https://toes.space' type TunnelConfig = Record const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000] const MAX_RECONNECT_ATTEMPTS = 10 const _closing = new Set() const _reconnectAttempts = new Map() const _reconnectTimers = new Map() const _tunnels = new Map() const _tunnelPorts = new Map() const configPath = () => join(TOES_DIR, 'tunnels.json') const loadConfig = (): TunnelConfig => { const path = configPath() if (!existsSync(path)) return {} try { return JSON.parse(readFileSync(path, 'utf-8')) } catch { return {} } } const saveConfig = (config: TunnelConfig) => { const dir = TOES_DIR if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n') } const toWsUrl = (url: string): string => url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') function buildTunnelUrl(subdomain: string): string { const parsed = new URL(SNEAKER_URL!) const port = parsed.port && parsed.port !== '80' && parsed.port !== '443' ? `:${parsed.port}` : '' return `${parsed.protocol}//${subdomain}.${parsed.hostname}${port}` } export const isTunnelsAvailable = (): boolean => !!SNEAKER_URL export function closeAllTunnels() { for (const [, timer] of _reconnectTimers) { clearTimeout(timer) } _reconnectTimers.clear() _reconnectAttempts.clear() for (const [name, tunnel] of _tunnels) { _closing.add(name) tunnel.close() } _tunnels.clear() _tunnelPorts.clear() } export function closeTunnel(appName: string) { cancelReconnect(appName) const tunnel = _tunnels.get(appName) if (!tunnel) return _closing.add(appName) tunnel.close() _tunnels.delete(appName) _tunnelPorts.delete(appName) const app = getApp(appName) if (app) { app.tunnelEnabled = false app.tunnelUrl = undefined update() } } export function unshareApp(appName: string) { closeTunnel(appName) const config = loadConfig() delete config[appName] saveConfig(config) const app = getApp(appName) if (app) { app.tunnelEnabled = false update() } } export function shareApp(appName: string, port: number) { if (!SNEAKER_URL) return const app = getApp(appName) if (app) { app.tunnelEnabled = true update() } // Save to config (even if port is 0, we want it enabled for when the app starts) const config = loadConfig() const existing = config[appName] config[appName] = existing ?? {} saveConfig(config) if (port > 0) { openTunnel(appName, port, config[appName]?.subdomain) } } export function openTunnelIfEnabled(appName: string, port: number) { if (!SNEAKER_URL) return const config = loadConfig() if (!config[appName]) return const app = getApp(appName) if (app) { app.tunnelEnabled = true update() } // Skip if tunnel is already open on the same port if (_tunnels.has(appName) && _tunnelPorts.get(appName) === port) return openTunnel(appName, port, config[appName]?.subdomain) } export function renameTunnelConfig(oldName: string, newName: string) { const config = loadConfig() if (!config[oldName]) return config[newName] = config[oldName]! delete config[oldName] saveConfig(config) } function cancelReconnect(appName: string, resetAttempts = true) { const timer = _reconnectTimers.get(appName) if (timer) { clearTimeout(timer) _reconnectTimers.delete(appName) } if (resetAttempts) _reconnectAttempts.delete(appName) } function openTunnel(appName: string, port: number, subdomain?: string, isReconnect = false) { // Cancel any pending reconnect timer to prevent duplicate loops // but preserve attempts counter during reconnection so backoff works cancelReconnect(appName, !isReconnect) // Close existing tunnel if any const existing = _tunnels.get(appName) if (existing) { _closing.add(appName) existing.close() _tunnels.delete(appName) } const wsUrl = toWsUrl(SNEAKER_URL!) const tunnel = connect({ server: wsUrl, app: appName, target: `http://localhost:${port}`, subdomain, reconnect: false, onOpen(assignedSubdomain) { hostLog(`Tunnel open: ${appName} -> ${assignedSubdomain}`) // Reset reconnect attempts on successful connection _reconnectAttempts.delete(appName) // Save subdomain for reconnection const config = loadConfig() if (config[appName]) { config[appName]!.subdomain = assignedSubdomain saveConfig(config) } const app = getApp(appName) if (app) { app.tunnelUrl = buildTunnelUrl(assignedSubdomain) update() } }, onClose() { _tunnels.delete(appName) _tunnelPorts.delete(appName) // Intentional close (unshareApp, closeAllTunnels, etc.) — don't reconnect if (_closing.delete(appName)) { hostLog(`Tunnel closed: ${appName}`) return } // Check retry limit const attempts = _reconnectAttempts.get(appName) ?? 0 if (attempts >= MAX_RECONNECT_ATTEMPTS) { hostLog(`Tunnel gave up: ${appName} (${attempts} failed attempts)`) _reconnectAttempts.delete(appName) return } const delayIndex = Math.min(attempts, RECONNECT_DELAYS.length - 1) const delay = RECONNECT_DELAYS[delayIndex]! _reconnectAttempts.set(appName, attempts + 1) hostLog(`Tunnel dropped: ${appName}, reconnecting in ${delay / 1000}s (attempt ${attempts + 1}/${MAX_RECONNECT_ATTEMPTS})...`) const app = getApp(appName) if (app) { app.tunnelUrl = undefined update() } const timer = setTimeout(() => { _reconnectTimers.delete(appName) // Only reconnect if still enabled in config const config = loadConfig() if (!config[appName]) return hostLog(`Tunnel reconnecting: ${appName}`) openTunnel(appName, port, config[appName]?.subdomain, true) }, delay) _reconnectTimers.set(appName, timer) }, onError(error) { hostLog(`Tunnel error (${appName}): ${error.message}`) }, }) _tunnels.set(appName, tunnel) _tunnelPorts.set(appName, port) }