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 _closing = new Set() 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 [name, tunnel] of _tunnels) { _closing.add(name) tunnel.close() } _tunnels.clear() _tunnelPorts.clear() } export function closeTunnel(appName: string) { 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 disableTunnel(appName: string) { closeTunnel(appName) const config = loadConfig() delete config[appName] saveConfig(config) const app = getApp(appName) if (app) { app.tunnelEnabled = false update() } } export function enableTunnel(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 openTunnel(appName: string, port: number, subdomain?: string) { // 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}`) // 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 (disableTunnel, closeAllTunnels, etc.) — don't reconnect if (_closing.delete(appName)) { hostLog(`Tunnel closed: ${appName}`) return } // Unexpected close — try to reconnect hostLog(`Tunnel dropped: ${appName}, reconnecting in 5s...`) const app = getApp(appName) if (app) { app.tunnelUrl = undefined update() } setTimeout(() => { // Only reconnect if still enabled in config const config = loadConfig() if (!config[appName]) return hostLog(`Tunnel reconnecting: ${appName}`) openTunnel(appName, port, config[appName]?.subdomain) }, 5000) }, onError(error) { hostLog(`Tunnel error (${appName}): ${error.message}`) }, }) _tunnels.set(appName, tunnel) _tunnelPorts.set(appName, port) }