Merge pull request 'Fix proxy and split server into modules' (#1) from probablycorey/fix-proxy-404s into main
Reviewed-on: #1
This commit is contained in:
commit
a56dfdc422
|
|
@ -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`.
|
Toes provides `PORT`, `DATA_DIR`, `APPS_DIR`, `TOES_URL`, `APP_URL`.
|
||||||
|
|
||||||
Tronbyt-specific vars (set via toes env config):
|
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)
|
- `SINGLE_USER_AUTO_LOGIN` — `true` for home network (default)
|
||||||
- `SYSTEM_APPS_AUTO_REFRESH` — `true` to keep community apps updated (default)
|
- `SYSTEM_APPS_AUTO_REFRESH` — `true` to keep community apps updated (default)
|
||||||
|
|
||||||
|
|
|
||||||
9
bun.lock
9
bun.lock
|
|
@ -4,9 +4,6 @@
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tronbyt",
|
"name": "tronbyt",
|
||||||
"dependencies": {
|
|
||||||
"@because/hype": "0.0.6",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
|
|
@ -16,18 +13,12 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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
113
src/binary.ts
Normal 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
68
src/proxy.ts
Normal 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 }
|
||||||
|
}
|
||||||
132
src/server.ts
132
src/server.ts
|
|
@ -1,71 +1,14 @@
|
||||||
import { join } from 'path'
|
import { createProxy, type WsData } from './proxy'
|
||||||
import { unlinkSync } from 'fs'
|
import { isHealthy, isRunning, shutdown, spawn } from './binary'
|
||||||
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 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
|
const { proxyFetch, websocket } = createProxy(isHealthy, isRunning)
|
||||||
|
|
||||||
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 server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
hostname: '::',
|
||||||
idleTimeout: 255,
|
idleTimeout: 255,
|
||||||
|
|
||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
|
|
@ -88,72 +31,7 @@ const server = Bun.serve({
|
||||||
|
|
||||||
console.log(`Listening on port ${server.port}`)
|
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 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('SIGTERM', shutdown)
|
||||||
process.on('SIGINT', shutdown)
|
process.on('SIGINT', shutdown)
|
||||||
|
|
||||||
spawnGoServer()
|
spawn(DATA_DIR)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user