tronbyt/src/server.ts
Corey Johnson 89bf052ca1 Return 200 from /ok while Go server is still starting
Toes health-checks /ok during startup. The Go server can take a
while to become healthy (cloning system apps repo on first run),
so return 200 while the process is alive but not yet ready.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:19:27 -07:00

203 lines
5.6 KiB
TypeScript

import { join } from 'path'
import { accessSync, chmodSync, constants, mkdirSync, unlinkSync } from 'fs'
import type { ServerWebSocket, Subprocess } from 'bun'
const DATA_DIR = process.env.DATA_DIR
const PORT = Number(process.env.PORT) || 3000
const SOCKET_PATH = DATA_DIR ? join(DATA_DIR, 'tronbyt.sock') : ''
const BIN_DIR = join(import.meta.dir, '..', 'bin')
let goHealthy = false
let goProcess: Subprocess | undefined
interface WsData {
path: string
protocols: string[]
}
const upstreams = new Map<ServerWebSocket<WsData>, WebSocket>()
// Proxy fetch to Go server over unix socket
function proxyFetch(req: Request): Promise<Response> | Response {
const url = new URL(req.url)
if (url.pathname === '/ok') {
if (!goHealthy) return new Response('starting', { status: goProcess ? 200 : 503 })
return fetch('http://localhost/health', { unix: SOCKET_PATH })
.then((r) => (r.ok ? new Response('ok') : new Response('unhealthy', { status: 503 })))
.catch(() => new Response('unhealthy', { status: 503 }))
}
return fetch(`http://localhost${url.pathname}${url.search}`, {
method: req.method,
headers: req.headers,
body: req.body,
unix: SOCKET_PATH,
}).catch((e) => {
console.error('Proxy error:', e)
return new Response('Tronbyt server is not responding', { status: 502 })
})
}
// WebSocket proxy
const websocket = {
open(ws: ServerWebSocket<WsData>) {
const upstream = new WebSocket(`ws+unix://${SOCKET_PATH}:${ws.data.path}`, ws.data.protocols)
upstream.binaryType = 'arraybuffer'
upstreams.set(ws, upstream)
upstream.addEventListener('message', (e) => ws.send(e.data as string | ArrayBuffer))
upstream.addEventListener('close', () => { upstreams.delete(ws); ws.close() })
upstream.addEventListener('error', () => { upstreams.delete(ws); ws.close() })
},
message(ws: ServerWebSocket<WsData>, msg: string | ArrayBuffer | Uint8Array) {
const upstream = upstreams.get(ws)
if (upstream?.readyState === WebSocket.OPEN) upstream.send(msg)
},
close(ws: ServerWebSocket<WsData>) {
upstreams.get(ws)?.close()
upstreams.delete(ws)
},
}
// Server
const server = Bun.serve({
port: PORT,
idleTimeout: 255,
fetch(req, server) {
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
const url = new URL(req.url)
const protocolHeader = req.headers.get('sec-websocket-protocol')
const protocols = protocolHeader ? protocolHeader.split(',').map((p) => p.trim()) : []
const headers: Record<string, string> = {}
if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader
if (server.upgrade(req, { data: { path: url.pathname + url.search, protocols }, headers })) return
return new Response('WebSocket upgrade failed', { status: 500 })
}
return proxyFetch(req)
},
websocket,
})
console.log(`Listening on port ${server.port}`)
// Go binary management
function getBinaryName(): string {
const platform = process.platform === 'darwin' ? 'darwin' : 'linux'
const arch = process.arch === 'x64' ? 'amd64' : 'arm64'
return `tronbyt-server-${platform}-${arch}`
}
async function waitForHealthy(maxAttempts = 60): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const resp = await fetch('http://localhost/health', { unix: SOCKET_PATH })
if (resp.ok) return true
} catch {}
await Bun.sleep(1000)
}
return false
}
async function downloadBinary(binPath: string): Promise<boolean> {
const name = getBinaryName()
const url = `https://github.com/tronbyt/server/releases/latest/download/${name}`
console.log(`Downloading ${name}...`)
try {
const resp = await fetch(url, { redirect: 'follow' })
if (!resp.ok) {
console.error(`Download failed: ${resp.status} ${resp.statusText}`)
return false
}
mkdirSync(BIN_DIR, { recursive: true })
await Bun.write(binPath, resp)
chmodSync(binPath, 0o755)
console.log('Download complete')
return true
} catch (e) {
console.error('Download failed:', e)
return false
}
}
function validate(): string | undefined {
if (!DATA_DIR) return 'DATA_DIR env var is not set — toes should provide this automatically'
const binPath = join(BIN_DIR, getBinaryName())
try {
accessSync(binPath, constants.X_OK)
} catch {
return `Binary not found or not executable: ${binPath}`
}
}
async function spawnGoServer() {
const binPath = join(BIN_DIR, getBinaryName())
if (!(await Bun.file(binPath).exists())) {
if (!(await downloadBinary(binPath))) return
}
const error = validate()
if (error) {
console.error(`Setup error: ${error}`)
return
}
try { unlinkSync(SOCKET_PATH) } catch {}
console.log('Starting tronbyt server...')
goProcess = Bun.spawn([binPath], {
env: {
...process.env,
TRONBYT_UNIX_SOCKET: SOCKET_PATH,
DATA_DIR,
DB_DSN: join(DATA_DIR!, 'tronbyt.db'),
PRODUCTION: process.env.PRODUCTION ?? 'false',
SINGLE_USER_AUTO_LOGIN: process.env.SINGLE_USER_AUTO_LOGIN ?? 'true',
SYSTEM_APPS_AUTO_REFRESH: process.env.SYSTEM_APPS_AUTO_REFRESH ?? 'true',
},
stdout: 'inherit',
stderr: 'inherit',
})
goProcess.exited.then((code) => {
console.log(`Tronbyt server exited with code ${code}`)
goHealthy = false
goProcess = undefined
})
if (await waitForHealthy()) {
goHealthy = true
console.log('Tronbyt server is healthy')
} else {
console.error('Tronbyt server failed to become healthy')
}
}
function shutdown() {
if (!goProcess) return
console.log('Shutting down tronbyt server...')
goProcess.kill('SIGTERM')
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
spawnGoServer()