206 lines
5.0 KiB
TypeScript
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)
|
|
}
|