Compare commits

..

17 Commits

Author SHA1 Message Date
542b8f06ca Remove unused dependencies from lock file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:31:21 -07:00
869fd18d42 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 <noreply@anthropic.com>
2026-03-10 19:36:13 -07:00
383f6a8143 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 <noreply@anthropic.com>
2026-03-10 19:32:42 -07:00
24b9629f0f 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 <noreply@anthropic.com>
2026-03-10 19:29:01 -07:00
019fca14f1 Update CLAUDE.md to reflect PRODUCTION=true default
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:24:10 -07:00
3bd144c88e Default PRODUCTION to true for system apps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:23:40 -07:00
08e80a2f92 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 <noreply@anthropic.com>
2026-03-10 19:11:37 -07:00
74155d3d0a 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 <noreply@anthropic.com>
2026-03-10 19:04:04 -07:00
e197a6d3bb 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 <noreply@anthropic.com>
2026-03-10 18:57:19 -07:00
a344e293f1 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 <noreply@anthropic.com>
2026-03-10 18:02:12 -07:00
02482d9beb 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 <noreply@anthropic.com>
2026-03-10 17:57:11 -07:00
82febdbf7f 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 <noreply@anthropic.com>
2026-03-10 17:45:27 -07:00
3fe4116ec7 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 <noreply@anthropic.com>
2026-03-10 17:36:24 -07:00
be6719733b 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 <noreply@anthropic.com>
2026-03-10 17:35:11 -07:00
4e2e0c9c5f 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 <noreply@anthropic.com>
2026-03-10 17:34:07 -07:00
6774b456a9 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 <noreply@anthropic.com>
2026-03-10 17:31:19 -07:00
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
5 changed files with 188 additions and 177 deletions

View File

@ -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)

View File

@ -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=="],

113
src/binary.ts Normal file
View File

@ -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<boolean> {
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<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(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')
}

68
src/proxy.ts Normal file
View File

@ -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<ServerWebSocket<WsData>, WebSocket>()
export function createProxy(isHealthy: () => boolean, isRunning: () => boolean) {
async 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(`${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<WsData>) {
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<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)
},
}
return { proxyFetch, websocket }
}

View File

@ -1,71 +1,14 @@
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')
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') {
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 { proxyFetch, websocket } = createProxy(isHealthy, isRunning)
const server = Bun.serve({
port: PORT,
hostname: '::',
idleTimeout: 255,
fetch(req, server) {
@ -88,111 +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<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}`)
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)