From 84ce472c87e54a26ebe8b6e7d523ab39923820a3 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:16:05 -0700 Subject: [PATCH 01/18] Auto-download Go binary on startup and validate setup The postinstall script doesn't run on toes (package.json is transformed during deploy), so the binary was never downloaded. Now the server downloads it from GitHub releases if missing. Added validate() to catch missing DATA_DIR and non-executable binary with clear error messages. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index a9f6ae8..dfa15cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,10 @@ import { join } from 'path' -import { unlinkSync } from 'fs' +import { accessSync, chmodSync, constants, mkdirSync, unlinkSync } from 'fs' import type { ServerWebSocket, Subprocess } from 'bun' -const DATA_DIR = process.env.DATA_DIR! +const DATA_DIR = process.env.DATA_DIR const PORT = Number(process.env.PORT) || 3000 -const SOCKET_PATH = join(DATA_DIR, 'tronbyt.sock') +const SOCKET_PATH = DATA_DIR ? join(DATA_DIR, 'tronbyt.sock') : '' const BIN_DIR = join(import.meta.dir, '..', 'bin') let goProcess: Subprocess | undefined @@ -107,13 +107,52 @@ async function waitForHealthy(maxAttempts = 60): Promise { 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(): 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())) { - console.error(`Binary not found: ${binPath}`) - console.error(`Download from https://github.com/tronbyt/server/releases`) - console.error(`Expected: ${getBinaryName()}`) + if (!(await downloadBinary(binPath))) return + } + + const error = validate() + if (error) { + console.error(`Setup error: ${error}`) return } @@ -126,7 +165,7 @@ async function spawnGoServer() { ...process.env, TRONBYT_UNIX_SOCKET: SOCKET_PATH, DATA_DIR, - DB_DSN: join(DATA_DIR, 'tronbyt.db'), + 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', From 89bf052ca1c3e0c397bbaa5710519b2773b8ebb3 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:19:27 -0700 Subject: [PATCH 02/18] 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 --- src/server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server.ts b/src/server.ts index dfa15cf..d658ec8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ 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 { @@ -22,6 +23,7 @@ function proxyFetch(req: Request): Promise | 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 })) @@ -176,10 +178,12 @@ async function spawnGoServer() { 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') From 6774b456a97fd97cb8a2145c0aaeb72cbe8d2cff Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:31:19 -0700 Subject: [PATCH 03/18] Don't forward body for GET/HEAD requests in proxy Passing req.body (a ReadableStream) for GET requests could cause the unix socket fetch to hang waiting for body data, especially when the upstream toes proxy has already stripped content-length. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index d658ec8..9cbb65d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,10 +29,12 @@ function proxyFetch(req: Request): Promise | Response { .catch(() => new Response('unhealthy', { status: 503 })) } + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, headers: req.headers, - body: req.body, + body: hasBody ? req.body : undefined, unix: SOCKET_PATH, }).catch((e) => { console.error('Proxy error:', e) From 4e2e0c9c5f5d314e0fd77c076c6e32c4b27616d4 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:34:07 -0700 Subject: [PATCH 04/18] Listen on 0.0.0.0 to ensure IPv4 connectivity tronbyt.toes.local resolves to an IPv4 address while toes.local uses IPv6, so the toes proxy couldn't reach the Bun server. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server.ts b/src/server.ts index 9cbb65d..1e98369 100644 --- a/src/server.ts +++ b/src/server.ts @@ -70,6 +70,7 @@ const websocket = { const server = Bun.serve({ port: PORT, + hostname: '0.0.0.0', idleTimeout: 255, fetch(req, server) { From be6719733b619edeb6e606f8e769f82170732eb5 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:35:11 -0700 Subject: [PATCH 05/18] Listen on :: (dual-stack) instead of 0.0.0.0 Toes proxy fetches via localhost which may resolve to ::1 on Linux. Listening on :: accepts both IPv4 and IPv6 connections. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 1e98369..1c88719 100644 --- a/src/server.ts +++ b/src/server.ts @@ -70,7 +70,7 @@ const websocket = { const server = Bun.serve({ port: PORT, - hostname: '0.0.0.0', + hostname: '::', idleTimeout: 255, fetch(req, server) { From 3fe4116ec729d6b88c10235607acdbaa490658e0 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:36:24 -0700 Subject: [PATCH 06/18] Strip accept-encoding to avoid ZlibError in proxy chain The Go server returns gzip responses, but when Bun proxies these through to the toes proxy, the double-proxy causes a ZlibError during decompression. Stripping accept-encoding tells the Go server to send uncompressed responses. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 1c88719..2ed86cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,10 +30,12 @@ function proxyFetch(req: Request): Promise | Response { } const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + const headers = new Headers(req.headers) + headers.delete('accept-encoding') return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, - headers: req.headers, + headers, body: hasBody ? req.body : undefined, unix: SOCKET_PATH, }).catch((e) => { From 82febdbf7fd1c70c454c2274158f5e649a51f8af Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:45:27 -0700 Subject: [PATCH 07/18] Strip content-encoding from proxied responses Bun decompresses gzip responses internally but leaves the Content-Encoding: gzip header, causing browsers to fail trying to decompress already-decompressed content. Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server.ts b/src/server.ts index 2ed86cf..ad6d16d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,11 @@ function proxyFetch(req: Request): Promise | Response { headers, body: hasBody ? req.body : undefined, unix: SOCKET_PATH, + }).then((r) => { + const respHeaders = new Headers(r.headers) + respHeaders.delete('content-encoding') + respHeaders.delete('content-length') + return new Response(r.body, { status: r.status, headers: respHeaders }) }).catch((e) => { console.error('Proxy error:', e) return new Response('Tronbyt server is not responding', { status: 502 }) From 02482d9bebbd661e9f891219c32387d212d172e0 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 17:57:11 -0700 Subject: [PATCH 08/18] Split server.ts into proxy, binary, and server modules - proxy.ts: HTTP and WebSocket proxy to Go unix socket - binary.ts: Go binary download, validation, spawning, lifecycle - server.ts: entry point wiring everything together Co-Authored-By: Claude Opus 4.6 --- src/binary.ts | 115 +++++++++++++++++++++++++++++++ src/proxy.ts | 64 +++++++++++++++++ src/server.ts | 185 ++------------------------------------------------ 3 files changed, 185 insertions(+), 179 deletions(-) create mode 100644 src/binary.ts create mode 100644 src/proxy.ts diff --git a/src/binary.ts b/src/binary.ts new file mode 100644 index 0000000..5b75fe2 --- /dev/null +++ b/src/binary.ts @@ -0,0 +1,115 @@ +import { join } from 'path' +import { accessSync, chmodSync, constants, mkdirSync, unlinkSync } from 'fs' +import type { Subprocess } from 'bun' + +const BIN_DIR = join(import.meta.dir, '..', 'bin') + +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(socketPath: string, maxAttempts = 60): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const resp = await fetch('http://localhost/health', { unix: socketPath }) + 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, socketPath: 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 + } + + try { unlinkSync(socketPath) } catch {} + + console.log('Starting tronbyt server...') + + proc = Bun.spawn([binPath], { + env: { + ...process.env, + TRONBYT_UNIX_SOCKET: socketPath, + DATA_DIR: dataDir, + DB_DSN: join(dataDir, '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', + }) + + proc.exited.then((code) => { + console.log(`Tronbyt server exited with code ${code}`) + healthy = false + proc = undefined + }) + + if (await waitForHealthy(socketPath)) { + 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..727ba4e --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,64 @@ +import type { ServerWebSocket } from 'bun' + +export interface WsData { + path: string + protocols: string[] +} + +const upstreams = new Map, WebSocket>() + +export function createProxy(socketPath: string, isHealthy: () => boolean, isRunning: () => boolean) { + function proxyFetch(req: Request): Promise | Response { + const url = new URL(req.url) + + if (url.pathname === '/ok') { + if (!isHealthy()) return new Response('starting', { status: isRunning() ? 200 : 503 }) + return fetch('http://localhost/health', { unix: socketPath }) + .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 headers = new Headers(req.headers) + headers.delete('accept-encoding') + + return fetch(`http://localhost${url.pathname}${url.search}`, { + method: req.method, + headers, + body: hasBody ? req.body : undefined, + unix: socketPath, + }).then((r) => { + const respHeaders = new Headers(r.headers) + respHeaders.delete('content-encoding') + respHeaders.delete('content-length') + return new Response(r.body, { status: r.status, headers: respHeaders }) + }).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+unix://${socketPath}:${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 ad6d16d..aef6eb7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,79 +1,12 @@ import { join } from 'path' -import { accessSync, chmodSync, constants, mkdirSync, 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 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') +const SOCKET_PATH = join(DATA_DIR, 'tronbyt.sock') -let goHealthy = false -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') { - 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 })) - } - - const hasBody = req.method !== 'GET' && req.method !== 'HEAD' - const headers = new Headers(req.headers) - headers.delete('accept-encoding') - - return fetch(`http://localhost${url.pathname}${url.search}`, { - method: req.method, - headers, - body: hasBody ? req.body : undefined, - unix: SOCKET_PATH, - }).then((r) => { - const respHeaders = new Headers(r.headers) - respHeaders.delete('content-encoding') - respHeaders.delete('content-length') - return new Response(r.body, { status: r.status, headers: respHeaders }) - }).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(SOCKET_PATH, isHealthy, isRunning) const server = Bun.serve({ port: PORT, @@ -100,113 +33,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 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(): 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() +spawn(DATA_DIR, SOCKET_PATH) From a344e293f1b1fc32d4472995317057d4c6c54485 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 18:02:12 -0700 Subject: [PATCH 09/18] Buffer request body before proxying to unix socket Streaming req.body through the double proxy (toes -> bun -> go) caused POST requests to fail. Buffer the body as an ArrayBuffer first so content-length is set correctly. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 727ba4e..b282a9f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,7 +8,7 @@ export interface WsData { const upstreams = new Map, WebSocket>() export function createProxy(socketPath: string, isHealthy: () => boolean, isRunning: () => boolean) { - function proxyFetch(req: Request): Promise | Response { + async function proxyFetch(req: Request): Promise { const url = new URL(req.url) if (url.pathname === '/ok') { @@ -21,11 +21,12 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn const hasBody = req.method !== 'GET' && req.method !== 'HEAD' const headers = new Headers(req.headers) headers.delete('accept-encoding') + const body = hasBody ? await req.arrayBuffer() : undefined return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, headers, - body: hasBody ? req.body : undefined, + body, unix: socketPath, }).then((r) => { const respHeaders = new Headers(r.headers) From e197a6d3bb59082b599b8d8232a9b88f43982bc4 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 18:57:19 -0700 Subject: [PATCH 10/18] Use decompress: false for transparent proxy passthrough Bun's fetch has a decompress option that passes responses through without interpreting content-encoding. This replaces all the manual header stripping workarounds. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index b282a9f..8c5f59b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,20 +19,14 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn } const hasBody = req.method !== 'GET' && req.method !== 'HEAD' - const headers = new Headers(req.headers) - headers.delete('accept-encoding') const body = hasBody ? await req.arrayBuffer() : undefined return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, - headers, + headers: req.headers, body, unix: socketPath, - }).then((r) => { - const respHeaders = new Headers(r.headers) - respHeaders.delete('content-encoding') - respHeaders.delete('content-length') - return new Response(r.body, { status: r.status, headers: respHeaders }) + decompress: false, }).catch((e) => { console.error('Proxy error:', e) return new Response('Tronbyt server is not responding', { status: 502 }) From 74155d3d0a0db90e17589bcfc846e2b605762f13 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:04:04 -0700 Subject: [PATCH 11/18] Strip accept-encoding to prevent gzip in proxy chain Toes' Bun fetch auto-decompresses responses. If Go sends gzip through our proxy, toes gets raw gzip bytes it can't handle. Stripping accept-encoding tells Go to send uncompressed. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 8c5f59b..b78d555 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -20,13 +20,14 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn const hasBody = req.method !== 'GET' && req.method !== 'HEAD' const body = hasBody ? await req.arrayBuffer() : undefined + const headers = new Headers(req.headers) + headers.delete('accept-encoding') return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, - headers: req.headers, + headers, body, unix: socketPath, - decompress: false, }).catch((e) => { console.error('Proxy error:', e) return new Response('Tronbyt server is not responding', { status: 502 }) From 08e80a2f920fc8b1e1bddf5c4a85d739cdce0a9c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:11:37 -0700 Subject: [PATCH 12/18] Strip content-encoding/length from Go responses Go server always sends gzip regardless of accept-encoding. Bun decompresses it automatically but leaves the content-encoding header. Strip it so the toes proxy doesn't try to decompress the already-decompressed response. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index b78d555..b4afe16 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -20,14 +20,19 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn const hasBody = req.method !== 'GET' && req.method !== 'HEAD' const body = hasBody ? await req.arrayBuffer() : undefined - const headers = new Headers(req.headers) - headers.delete('accept-encoding') return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, - headers, + headers: req.headers, body, unix: socketPath, + }).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 }) From 3bd144c88ec7f0702df521f25e8db7f3097bf40d Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:23:40 -0700 Subject: [PATCH 13/18] Default PRODUCTION to true for system apps Co-Authored-By: Claude Opus 4.6 --- src/binary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/binary.ts b/src/binary.ts index 5b75fe2..ce8707f 100644 --- a/src/binary.ts +++ b/src/binary.ts @@ -86,7 +86,7 @@ export async function spawn(dataDir: string, socketPath: string) { TRONBYT_UNIX_SOCKET: socketPath, DATA_DIR: dataDir, DB_DSN: join(dataDir, 'tronbyt.db'), - PRODUCTION: process.env.PRODUCTION ?? 'false', + 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', }, From 019fca14f1e8eef8d367531d71109ed8288d05c7 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:24:10 -0700 Subject: [PATCH 14/18] Update CLAUDE.md to reflect PRODUCTION=true default Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 24b9629f0f339780fc411214d219c0f988a5f60e Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:29:01 -0700 Subject: [PATCH 15/18] Forward client IP to Go server for auto-login trust The Go server checks if the client is on a private network for auto-login. Since we proxy over a unix socket, the Go server can't see the real client IP. Forward it via X-Forwarded-For and X-Real-IP headers. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 8 ++++++-- src/server.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index b4afe16..5477f51 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,7 +8,7 @@ export interface WsData { const upstreams = new Map, WebSocket>() export function createProxy(socketPath: string, isHealthy: () => boolean, isRunning: () => boolean) { - async function proxyFetch(req: Request): Promise { + async function proxyFetch(req: Request, clientIP?: string): Promise { const url = new URL(req.url) if (url.pathname === '/ok') { @@ -20,10 +20,14 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn const hasBody = req.method !== 'GET' && req.method !== 'HEAD' const body = hasBody ? await req.arrayBuffer() : undefined + const headers = new Headers(req.headers) + const forwardedFor = req.headers.get('x-forwarded-for') + headers.set('x-forwarded-for', forwardedFor ? `${forwardedFor}, ${clientIP}` : (clientIP ?? '')) + headers.set('x-real-ip', clientIP ?? '') return fetch(`http://localhost${url.pathname}${url.search}`, { method: req.method, - headers: req.headers, + headers, body, unix: socketPath, }).then((r) => { diff --git a/src/server.ts b/src/server.ts index aef6eb7..596dd7f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,7 +25,9 @@ const server = Bun.serve({ return new Response('WebSocket upgrade failed', { status: 500 }) } - return proxyFetch(req) + const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + || server.requestIP(req)?.address + return proxyFetch(req, clientIP) }, websocket, From 383f6a81434b2e09e20042e4a014352f82b372e1 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:32:42 -0700 Subject: [PATCH 16/18] Switch from unix socket to TCP proxy Proxy to Go server on 127.0.0.1:8000 instead of unix socket. Go sees localhost connections as trusted for auto-login. Removes all the unix socket, IP forwarding, and socket path plumbing complexity. Co-Authored-By: Claude Opus 4.6 --- src/binary.ts | 14 ++++++-------- src/proxy.ts | 20 +++++++++----------- src/server.ts | 10 +++------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/binary.ts b/src/binary.ts index ce8707f..87ca805 100644 --- a/src/binary.ts +++ b/src/binary.ts @@ -1,8 +1,9 @@ import { join } from 'path' -import { accessSync, chmodSync, constants, mkdirSync, unlinkSync } from 'fs' +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 @@ -16,10 +17,10 @@ function getBinaryName(): string { return `tronbyt-server-${platform}-${arch}` } -async function waitForHealthy(socketPath: string, maxAttempts = 60): Promise { +async function waitForHealthy(maxAttempts = 60): Promise { for (let i = 0; i < maxAttempts; i++) { try { - const resp = await fetch('http://localhost/health', { unix: socketPath }) + const resp = await fetch(`http://127.0.0.1:${GO_PORT}/health`) if (resp.ok) return true } catch {} await Bun.sleep(1000) @@ -63,7 +64,7 @@ function validate(dataDir: string): string | undefined { } } -export async function spawn(dataDir: string, socketPath: string) { +export async function spawn(dataDir: string) { const binPath = join(BIN_DIR, getBinaryName()) if (!(await Bun.file(binPath).exists())) { @@ -76,14 +77,11 @@ export async function spawn(dataDir: string, socketPath: string) { return } - try { unlinkSync(socketPath) } catch {} - console.log('Starting tronbyt server...') proc = Bun.spawn([binPath], { env: { ...process.env, - TRONBYT_UNIX_SOCKET: socketPath, DATA_DIR: dataDir, DB_DSN: join(dataDir, 'tronbyt.db'), PRODUCTION: process.env.PRODUCTION ?? 'true', @@ -100,7 +98,7 @@ export async function spawn(dataDir: string, socketPath: string) { proc = undefined }) - if (await waitForHealthy(socketPath)) { + if (await waitForHealthy()) { healthy = true console.log('Tronbyt server is healthy') } else { diff --git a/src/proxy.ts b/src/proxy.ts index 5477f51..39e30ed 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,31 +5,29 @@ export interface WsData { protocols: string[] } +const GO_PORT = 8000 +const GO_BASE = `http://127.0.0.1:${GO_PORT}` + const upstreams = new Map, WebSocket>() -export function createProxy(socketPath: string, isHealthy: () => boolean, isRunning: () => boolean) { - async function proxyFetch(req: Request, clientIP?: string): Promise { +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('http://localhost/health', { unix: socketPath }) + 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 - const headers = new Headers(req.headers) - const forwardedFor = req.headers.get('x-forwarded-for') - headers.set('x-forwarded-for', forwardedFor ? `${forwardedFor}, ${clientIP}` : (clientIP ?? '')) - headers.set('x-real-ip', clientIP ?? '') - return fetch(`http://localhost${url.pathname}${url.search}`, { + return fetch(`${GO_BASE}${url.pathname}${url.search}`, { method: req.method, - headers, + headers: req.headers, body, - unix: socketPath, }).then((r) => { // Bun auto-decompresses gzip but leaves content-encoding header. // Strip it so the next proxy layer doesn't try to decompress again. @@ -45,7 +43,7 @@ export function createProxy(socketPath: string, isHealthy: () => boolean, isRunn const websocket = { open(ws: ServerWebSocket) { - const upstream = new WebSocket(`ws+unix://${socketPath}:${ws.data.path}`, ws.data.protocols) + const upstream = new WebSocket(`ws://127.0.0.1:${GO_PORT}${ws.data.path}`, ws.data.protocols) upstream.binaryType = 'arraybuffer' upstreams.set(ws, upstream) diff --git a/src/server.ts b/src/server.ts index 596dd7f..b75e4ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,10 @@ -import { join } from 'path' 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 { proxyFetch, websocket } = createProxy(SOCKET_PATH, isHealthy, isRunning) +const { proxyFetch, websocket } = createProxy(isHealthy, isRunning) const server = Bun.serve({ port: PORT, @@ -25,9 +23,7 @@ const server = Bun.serve({ return new Response('WebSocket upgrade failed', { status: 500 }) } - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - || server.requestIP(req)?.address - return proxyFetch(req, clientIP) + return proxyFetch(req) }, websocket, @@ -38,4 +34,4 @@ console.log(`Listening on port ${server.port}`) process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) -spawn(DATA_DIR, SOCKET_PATH) +spawn(DATA_DIR) From 869fd18d4225849c46048270ebef1f242d89279c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 19:36:13 -0700 Subject: [PATCH 17/18] Use redirect: manual to let browser handle redirects Bun's fetch was following Go's 303 redirects internally, which caused ECONNRESET errors during the auto-login redirect chain. Let the browser handle redirects instead. Co-Authored-By: Claude Opus 4.6 --- src/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/proxy.ts b/src/proxy.ts index 39e30ed..f84a536 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -28,6 +28,7 @@ export function createProxy(isHealthy: () => boolean, isRunning: () => boolean) 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. From 542b8f06ca6acdd733b239fa0f2fa7ed89705f2b Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 11 Mar 2026 09:31:21 -0700 Subject: [PATCH 18/18] Remove unused dependencies from lock file Co-Authored-By: Claude Opus 4.6 --- bun.lock | 9 --------- 1 file changed, 9 deletions(-) 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=="],