diff --git a/CLAUDE.md b/CLAUDE.md index 22a060e..5fc36a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Tidbyt device → tronbyt.toes.local → toes → Bun (PORT) → Go binary (unix Toes provides `PORT`, `DATA_DIR`, `APPS_DIR`, `TOES_URL`, `APP_URL`. Tronbyt-specific vars (set via toes env config): -- `PRODUCTION` — `false` skips firmware downloads (default) +- `PRODUCTION` — `true` enables firmware downloads and system apps (default) - `SINGLE_USER_AUTO_LOGIN` — `true` for home network (default) - `SYSTEM_APPS_AUTO_REFRESH` — `true` to keep community apps updated (default) diff --git a/bun.lock b/bun.lock index 0e6d949..e807f03 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "tronbyt", - "dependencies": { - "@because/hype": "0.0.6", - }, "devDependencies": { "@types/bun": "latest", }, @@ -16,18 +13,12 @@ }, }, "packages": { - "@because/hype": ["@because/hype@0.0.6", "https://npm.nose.space/@because/hype/-/hype-0.0.6.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-WSRPNoeTBR3nRcPTqfbu6+FUaNenCo/sN/CB2Ism7oiJwTap1i+1AlWPa+MF1eMQlNd2AYRlA3AAu6F52j6/fA=="], - "@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/node": ["@types/node@25.4.0", "https://npm.nose.space/@types/node/-/node-25.4.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], "bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], - "hono": ["hono@4.12.7", "https://npm.nose.space/hono/-/hono-4.12.7.tgz", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="], - - "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/src/binary.ts b/src/binary.ts new file mode 100644 index 0000000..87ca805 --- /dev/null +++ b/src/binary.ts @@ -0,0 +1,113 @@ +import { join } from 'path' +import { accessSync, chmodSync, constants, mkdirSync } from 'fs' +import type { Subprocess } from 'bun' + +const BIN_DIR = join(import.meta.dir, '..', 'bin') +const GO_PORT = 8000 + +let healthy = false +let proc: Subprocess | undefined + +export const isHealthy = () => healthy +export const isRunning = () => !!proc + +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 { + for (let i = 0; i < maxAttempts; i++) { + try { + const resp = await fetch(`http://127.0.0.1:${GO_PORT}/health`) + if (resp.ok) return true + } catch {} + await Bun.sleep(1000) + } + return false +} + +async function downloadBinary(binPath: string): Promise { + 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(dataDir: string): string | undefined { + if (!dataDir) 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}` + } +} + +export async function spawn(dataDir: string) { + const binPath = join(BIN_DIR, getBinaryName()) + + if (!(await Bun.file(binPath).exists())) { + if (!(await downloadBinary(binPath))) return + } + + const error = validate(dataDir) + if (error) { + console.error(`Setup error: ${error}`) + return + } + + console.log('Starting tronbyt server...') + + proc = Bun.spawn([binPath], { + env: { + ...process.env, + DATA_DIR: dataDir, + DB_DSN: join(dataDir, 'tronbyt.db'), + PRODUCTION: process.env.PRODUCTION ?? 'true', + 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', + }) + + proc.exited.then((code) => { + console.log(`Tronbyt server exited with code ${code}`) + healthy = false + proc = undefined + }) + + if (await waitForHealthy()) { + healthy = true + console.log('Tronbyt server is healthy') + } else { + console.error('Tronbyt server failed to become healthy') + } +} + +export function shutdown() { + if (!proc) return + console.log('Shutting down tronbyt server...') + proc.kill('SIGTERM') +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..f84a536 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,68 @@ +import type { ServerWebSocket } from 'bun' + +export interface WsData { + path: string + protocols: string[] +} + +const GO_PORT = 8000 +const GO_BASE = `http://127.0.0.1:${GO_PORT}` + +const upstreams = new Map, WebSocket>() + +export function createProxy(isHealthy: () => boolean, isRunning: () => boolean) { + async function proxyFetch(req: Request): Promise { + const url = new URL(req.url) + + if (url.pathname === '/ok') { + if (!isHealthy()) return new Response('starting', { status: isRunning() ? 200 : 503 }) + return fetch(`${GO_BASE}/health`) + .then((r) => (r.ok ? new Response('ok') : new Response('unhealthy', { status: 503 }))) + .catch(() => new Response('unhealthy', { status: 503 })) + } + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + const body = hasBody ? await req.arrayBuffer() : undefined + + return fetch(`${GO_BASE}${url.pathname}${url.search}`, { + method: req.method, + headers: req.headers, + body, + redirect: 'manual', + }).then((r) => { + // Bun auto-decompresses gzip but leaves content-encoding header. + // Strip it so the next proxy layer doesn't try to decompress again. + const headers = new Headers(r.headers) + headers.delete('content-encoding') + headers.delete('content-length') + return new Response(r.body, { status: r.status, headers }) + }).catch((e) => { + console.error('Proxy error:', e) + return new Response('Tronbyt server is not responding', { status: 502 }) + }) + } + + const websocket = { + open(ws: ServerWebSocket) { + const upstream = new WebSocket(`ws://127.0.0.1:${GO_PORT}${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, msg: string | ArrayBuffer | Uint8Array) { + const upstream = upstreams.get(ws) + if (upstream?.readyState === WebSocket.OPEN) upstream.send(msg) + }, + + close(ws: ServerWebSocket) { + upstreams.get(ws)?.close() + upstreams.delete(ws) + }, + } + + return { proxyFetch, websocket } +} diff --git a/src/server.ts b/src/server.ts index a9f6ae8..b75e4ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,71 +1,14 @@ -import { join } from 'path' -import { unlinkSync } from 'fs' -import type { ServerWebSocket, Subprocess } from 'bun' +import { createProxy, type WsData } from './proxy' +import { isHealthy, isRunning, shutdown, spawn } from './binary' const DATA_DIR = process.env.DATA_DIR! const PORT = Number(process.env.PORT) || 3000 -const SOCKET_PATH = join(DATA_DIR, 'tronbyt.sock') -const BIN_DIR = join(import.meta.dir, '..', 'bin') -let goProcess: Subprocess | undefined - -interface WsData { - path: string - protocols: string[] -} - -const upstreams = new Map, WebSocket>() - -// Proxy fetch to Go server over unix socket - -function proxyFetch(req: Request): Promise | Response { - const url = new URL(req.url) - - if (url.pathname === '/ok') { - 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) { - 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, msg: string | ArrayBuffer | Uint8Array) { - const upstream = upstreams.get(ws) - if (upstream?.readyState === WebSocket.OPEN) upstream.send(msg) - }, - - close(ws: ServerWebSocket) { - upstreams.get(ws)?.close() - upstreams.delete(ws) - }, -} - -// Server +const { proxyFetch, websocket } = createProxy(isHealthy, isRunning) const server = Bun.serve({ port: PORT, + hostname: '::', idleTimeout: 255, fetch(req, server) { @@ -88,72 +31,7 @@ const server = Bun.serve({ 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 { - 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 spawnGoServer() { - const binPath = join(BIN_DIR, getBinaryName()) - - if (!(await Bun.file(binPath).exists())) { - console.error(`Binary not found: ${binPath}`) - console.error(`Download from https://github.com/tronbyt/server/releases`) - console.error(`Expected: ${getBinaryName()}`) - 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}`) - goProcess = undefined - }) - - if (await waitForHealthy()) { - 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() +spawn(DATA_DIR)