toes/src/server/tunnels.ts

206 lines
5.0 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 _closing = new Set<string>()
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 [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)
}