toes/src/server/tunnels.ts

249 lines
6.3 KiB
TypeScript

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<string, { subdomain?: string }>
const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000]
const MAX_RECONNECT_ATTEMPTS = 10
const _closing = new Set<string>()
const _reconnectAttempts = new Map<string, number>()
const _reconnectTimers = new Map<string, Timer>()
const _tunnels = new Map<string, Tunnel>()
const _tunnelPorts = new Map<string, number>()
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 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 cancelReconnect(appName: string) {
const timer = _reconnectTimers.get(appName)
if (timer) {
clearTimeout(timer)
_reconnectTimers.delete(appName)
}
_reconnectAttempts.delete(appName)
}
function openTunnel(appName: string, port: number, subdomain?: string) {
// Cancel any pending reconnect timer to prevent duplicate loops
cancelReconnect(appName)
// 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 (disableTunnel, 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)
}, delay)
_reconnectTimers.set(appName, timer)
},
onError(error) {
hostLog(`Tunnel error (${appName}): ${error.message}`)
},
})
_tunnels.set(appName, tunnel)
_tunnelPorts.set(appName, port)
}