ok
This commit is contained in:
parent
ca13947bfb
commit
387f746f8c
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
||||||
|
# pre-built go binary (per-platform)
|
||||||
|
bin/tronbyt-server*
|
||||||
|
!bin/.gitkeep
|
||||||
|
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
|
|
||||||
327
CLAUDE.md
327
CLAUDE.md
|
|
@ -1,307 +1,38 @@
|
||||||
# Toes - Guide to Writing Apps
|
# Tronbyt Toes App
|
||||||
|
|
||||||
Toes manages and runs web apps, each on its own port.
|
This is a [toes](/Users/corey/code/toes) app. See the [toes CLAUDE.md](/Users/corey/code/toes/CLAUDE.md) for the framework docs.
|
||||||
|
|
||||||
Apps are server-rendered TypeScript using **Hype** (wraps Hono) and **Forge** (CSS-in-JS).
|
Wraps the Tronbyt Go server (self-hosted Tidbyt replacement) as a toes-managed subprocess. Bun proxies all HTTP and WebSocket traffic to the Go binary over a unix socket.
|
||||||
|
|
||||||
Runtime is **Bun**.
|
## How It Works
|
||||||
|
|
||||||
## Required Components
|
|
||||||
|
|
||||||
Every toes app/tool must have:
|
|
||||||
|
|
||||||
1. **`.npmrc`** pointing to `registry=https://npm.nose.space` (the private registry for `@because/*` packages)
|
|
||||||
2. **`package.json`** with a `scripts.toes` entry (this is how toes discovers and runs apps)
|
|
||||||
3. **HTTP `GET /ok`** returning 200 (health check endpoint — toes polls this every 30s and restarts unresponsive apps)
|
|
||||||
|
|
||||||
## App vs Tool
|
|
||||||
|
|
||||||
An **app** shows in the sidebar and opens in its own browser tab.
|
|
||||||
|
|
||||||
A **tool** renders as a tab inside the dashboard (in an iframe). It receives `?app=<name>` to know the selected app. The only code difference is `"toes": { "tool": true }` in package.json and some extra imports from `@because/toes`.
|
|
||||||
|
|
||||||
## Required Files
|
|
||||||
|
|
||||||
Every app needs `.npmrc`, `tsconfig.json`, `package.json`, and `index.tsx`.
|
|
||||||
|
|
||||||
**.npmrc** -- always this exact content:
|
|
||||||
```
|
```
|
||||||
registry=https://npm.nose.space
|
Tidbyt device → tronbyt.toes.local → toes → Bun (PORT) → Go binary (unix socket)
|
||||||
```
|
```
|
||||||
|
|
||||||
**tsconfig.json** -- use exactly, do not improvise:
|
- `src/server/index.tsx` — spawns Go binary, proxies HTTP + WebSocket, health checks
|
||||||
```json
|
- `bin/` — pre-built Go binary (gitignored, per-platform)
|
||||||
{
|
- No UI of its own — Go server serves its own web dashboard
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
## Setup
|
||||||
"target": "ESNext",
|
|
||||||
"module": "Preserve",
|
1. Download the binary for your platform from https://github.com/tronbyt/server/releases
|
||||||
"moduleDetection": "force",
|
2. Place it in `bin/` (e.g. `bin/tronbyt-server-darwin-arm64`)
|
||||||
"jsx": "react-jsx",
|
3. `chmod +x bin/tronbyt-server-*`
|
||||||
"jsxImportSource": "hono/jsx",
|
4. On macOS: System Settings → Privacy & Security → Allow Anyway
|
||||||
"allowJs": true,
|
|
||||||
"moduleResolution": "bundler",
|
## Env Vars
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
Toes provides `PORT`, `DATA_DIR`, `APPS_DIR`, `TOES_URL`, `APP_URL`.
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
Tronbyt-specific vars (set via toes env config):
|
||||||
"skipLibCheck": true,
|
- `PRODUCTION` — `false` skips firmware downloads (default)
|
||||||
"noFallthroughCasesInSwitch": true,
|
- `SINGLE_USER_AUTO_LOGIN` — `true` for home network (default)
|
||||||
"noUncheckedIndexedAccess": true,
|
- `SYSTEM_APPS_AUTO_REFRESH` — `true` to keep community apps updated (default)
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noUnusedLocals": false,
|
## Device Config
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
Set the Tidbyt Image URL to:
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": ["src/server/*"],
|
|
||||||
"#*": ["src/client/*"],
|
|
||||||
"@*": ["src/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
http://tronbyt.toes.local/<device-id>/next
|
||||||
**package.json** for an app:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "my-app",
|
|
||||||
"private": true,
|
|
||||||
"module": "index.tsx",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": { "toes": "bun run --watch index.tsx" },
|
|
||||||
"toes": { "icon": "🖥️" },
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*"
|
|
||||||
},
|
|
||||||
"devDependencies": { "@types/bun": "latest" }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For a **tool**, add `@because/toes` to dependencies and set `"tool": true` (or a string for a custom tab label like `"tool": ".env"`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"toes": { "icon": "🔧", "tool": true },
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/toes": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hype
|
|
||||||
|
|
||||||
Hype wraps Hono. It adds `app.defaults` (the Bun server export), `app.sse()` for server-sent events, and `Hype.router()` for sub-routers. Everything else is standard Hono.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
const app = new Hype()
|
|
||||||
|
|
||||||
app.get('/', c => c.html(<h1>Hello</h1>))
|
|
||||||
app.get('/ok', c => c.text('ok')) // Health check -- required
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
Constructor options: `prettyHTML` (default true, tools should set false), `layout` (default true), `logging` (default true).
|
|
||||||
|
|
||||||
**SSE** -- the one non-Hono addition:
|
|
||||||
```tsx
|
|
||||||
app.sse('/stream', (send, c) => {
|
|
||||||
send({ hello: 'world' })
|
|
||||||
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
|
||||||
return () => clearInterval(interval) // cleanup on disconnect
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sub-routers:**
|
|
||||||
```tsx
|
|
||||||
const api = Hype.router()
|
|
||||||
api.get('/items', c => c.json([]))
|
|
||||||
app.route('/api', api) // mounts at /api/items
|
|
||||||
```
|
|
||||||
|
|
||||||
## Forge
|
|
||||||
|
|
||||||
Forge creates styled JSX components via `define()`. Properties use camelCase CSS. Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
|
||||||
|
|
||||||
const Box = define('Box', {
|
|
||||||
padding: 20,
|
|
||||||
borderRadius: '6px',
|
|
||||||
})
|
|
||||||
// <Box>content</Box> renders <div class="Box">content</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**`base`** -- set the HTML element (default `div`):
|
|
||||||
```tsx
|
|
||||||
const Button = define('Button', { base: 'button', padding: '8px 16px' })
|
|
||||||
const Link = define('Link', { base: 'a', textDecoration: 'none' })
|
|
||||||
```
|
|
||||||
|
|
||||||
**`states`** -- pseudo-classes:
|
|
||||||
```tsx
|
|
||||||
const Item = define('Item', {
|
|
||||||
padding: 12,
|
|
||||||
states: {
|
|
||||||
':hover': { backgroundColor: '#eee' },
|
|
||||||
':last-child': { borderBottom: 'none' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**`selectors`** -- nested CSS (`&` = the component):
|
|
||||||
```tsx
|
|
||||||
const List = define('List', {
|
|
||||||
selectors: {
|
|
||||||
'& > li:last-child': { borderBottom: 'none' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**`variants`** -- conditional styles via props:
|
|
||||||
```tsx
|
|
||||||
const Button = define('Button', {
|
|
||||||
base: 'button',
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
primary: { backgroundColor: '#2563eb', color: 'white' },
|
|
||||||
danger: { backgroundColor: '#dc2626', color: 'white' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// <Button variant="primary">Save</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Serving CSS** -- apps serve `stylesToCSS()` from a route. Tools prepend `baseStyles`:
|
|
||||||
```tsx
|
|
||||||
app.get('/styles.css', c =>
|
|
||||||
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Theme Tokens
|
|
||||||
|
|
||||||
Tools import `theme` from `@because/toes/tools`. It returns CSS variables that resolve per light/dark mode.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
|
||||||
|
|
||||||
const Container = define('Container', {
|
|
||||||
color: theme('colors-text'),
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Available tokens:
|
|
||||||
|
|
||||||
| Token | Use for |
|
|
||||||
|-------|---------|
|
|
||||||
| `colors-bg`, `colors-bgSubtle`, `colors-bgElement`, `colors-bgHover` | Backgrounds |
|
|
||||||
| `colors-text`, `colors-textMuted`, `colors-textFaint` | Text |
|
|
||||||
| `colors-border` | Borders |
|
|
||||||
| `colors-link` | Links |
|
|
||||||
| `colors-primary`, `colors-primaryText` | Primary actions |
|
|
||||||
| `colors-error`, `colors-dangerBorder`, `colors-dangerText` | Errors/danger |
|
|
||||||
| `colors-success`, `colors-successBg` | Success states |
|
|
||||||
| `colors-statusRunning`, `colors-statusStopped` | Status indicators |
|
|
||||||
| `fonts-sans`, `fonts-mono` | Font stacks |
|
|
||||||
| `spacing-xs` (4), `spacing-sm` (8), `spacing-md` (12), `spacing-lg` (16), `spacing-xl` (24) | Spacing (px) |
|
|
||||||
| `radius-md` (6px) | Border radius |
|
|
||||||
|
|
||||||
## Writing a Tool
|
|
||||||
|
|
||||||
Tools need three extra things vs apps:
|
|
||||||
|
|
||||||
1. `<ToolScript />` in `<body>` (handles dark mode + iframe height communication)
|
|
||||||
2. `baseStyles` prepended to CSS output
|
|
||||||
3. Handle the `?app=` query param
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
|
||||||
import type { Child } from 'hono/jsx'
|
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
|
||||||
|
|
||||||
const Container = define('Container', {
|
|
||||||
fontFamily: theme('fonts-sans'),
|
|
||||||
padding: '20px',
|
|
||||||
paddingTop: 0,
|
|
||||||
maxWidth: '800px',
|
|
||||||
margin: '0 auto',
|
|
||||||
color: theme('colors-text'),
|
|
||||||
})
|
|
||||||
|
|
||||||
function Layout({ title, children }: { title: string; children: Child }) {
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{title}</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ToolScript />
|
|
||||||
<Container>{children}</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/ok', c => c.text('ok'))
|
|
||||||
app.get('/styles.css', c =>
|
|
||||||
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get('/', async c => {
|
|
||||||
const appName = c.req.query('app')
|
|
||||||
if (!appName) return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
|
|
||||||
// ... tool logic using join(APPS_DIR, appName, 'current') for file paths
|
|
||||||
return c.html(<Layout title="My Tool">...</Layout>)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
**Environment variables** available to tools: `APPS_DIR`, `TOES_URL` (base URL of Toes server), `PORT`, `TOES_DIR`.
|
|
||||||
|
|
||||||
**Accessing app files:** always use `join(APPS_DIR, appName, 'current')`.
|
|
||||||
|
|
||||||
**Calling the Toes API:** `fetch(\`${TOES_URL}/api/apps\`)`, `fetch(\`${TOES_URL}/api/apps/${name}\`)`.
|
|
||||||
|
|
||||||
**Linking between tools:** `<a href={\`${TOES_URL}/tool/code?app=${appName}\`}>View Code</a>`.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
**Fire-and-forget with polling** -- for long-running ops, don't await in POST. Use `<meta http-equiv="refresh" content="2" />` to poll while running.
|
|
||||||
|
|
||||||
**Inline client JS** -- use `<script dangerouslySetInnerHTML={{ __html: script }} />`.
|
|
||||||
|
|
||||||
**Data persistence** -- use filesystem. `DATA_DIR` env var points to a per-app data directory.
|
|
||||||
|
|
||||||
## Cron Jobs
|
|
||||||
|
|
||||||
Place `.ts` files in an app's `cron/` directory:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const schedule = "day"
|
|
||||||
export default async function() {
|
|
||||||
console.log("Running at", new Date().toISOString())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Valid schedules: `"1 minute"`, `"5 minutes"`, `"15 minutes"`, `"30 minutes"`, `"hour"`, `"noon"`, `"midnight"`, `"day"`, `"week"`, `"sunday"` through `"saturday"`.
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
|
|
||||||
TS file organization order: imports, re-exports, const/lets, enums, interfaces, types, classes, functions, module init. Within each section, exports first (alphabetical), then non-exports (alphabetical).
|
|
||||||
|
|
||||||
Single-line functions: `const fn = () => {}`. Multi-line: `function name() {}`.
|
|
||||||
|
|
|
||||||
66
README.md
Normal file
66
README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Tronbyt
|
||||||
|
|
||||||
|
Run a [Tronbyt](https://github.com/tronbyt/server) server as a toes app. Tronbyt is a self-hosted replacement for Tidbyt's cloud — it renders Starlark apps into WebP frames and pushes them to Tidbyt LED displays over your local network.
|
||||||
|
|
||||||
|
This app spawns the pre-built Tronbyt Go binary as a subprocess and proxies all traffic (HTTP + WebSocket) to it over a unix socket. The Go server handles everything — web dashboard, device connections, app rendering. Bun just sits in front and makes it a good toes citizen.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### 1. Deploy to your toes server
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add toes http://git.toes.local/tronbyt
|
||||||
|
git push toes main
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing to the git tool deploys the app, runs `bun install` (which downloads the binary), and starts it automatically.
|
||||||
|
|
||||||
|
### 2. The binary
|
||||||
|
|
||||||
|
The Tronbyt Go binary is downloaded automatically during `bun install` via the postinstall script. It assumes you're running on a Raspberry Pi (linux-arm64) — which is what toes is designed for.
|
||||||
|
|
||||||
|
The binary is gitignored since it's platform-specific and ~50MB. If you need to re-download it or the postinstall didn't run, you can grab it manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -L -o bin/tronbyt-server-linux-arm64 \
|
||||||
|
https://github.com/tronbyt/server/releases/latest/download/tronbyt-server-linux-arm64
|
||||||
|
chmod +x bin/tronbyt-server-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
If the binary already exists in `bin/`, the postinstall skips the download.
|
||||||
|
|
||||||
|
### 3. First boot
|
||||||
|
|
||||||
|
On first start, the server clones the [community Starlark apps repo](https://github.com/tronbyt/apps) (~15 seconds). With `PRODUCTION=false` (the default), firmware downloads are skipped.
|
||||||
|
|
||||||
|
All data (SQLite DB, cloned apps, firmware) is stored in the app's `DATA_DIR`, which persists across restarts and deploys.
|
||||||
|
|
||||||
|
## Configure your Tidbyt
|
||||||
|
|
||||||
|
1. Flash your Tidbyt with [Tronbyt firmware](https://github.com/tronbyt/server/releases) (see firmware flashing docs)
|
||||||
|
2. During device setup, set the **Image URL** to:
|
||||||
|
```
|
||||||
|
http://tronbyt.toes.local/<device-id>/next
|
||||||
|
```
|
||||||
|
The device ID is assigned when you add the device in the Tronbyt web dashboard.
|
||||||
|
|
||||||
|
**Important:** The Image URL must be the full path including `/<device-id>/next` — the device uses it exactly as-is.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Set these through `toes env tronbyt` to override defaults:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PRODUCTION` | `false` | Set `true` to enable firmware downloads for OTA updates |
|
||||||
|
| `SINGLE_USER_AUTO_LOGIN` | `true` | Auto-login without password (good for home network) |
|
||||||
|
| `SYSTEM_APPS_AUTO_REFRESH` | `true` | Auto-refresh community apps repo every 12h |
|
||||||
|
|
||||||
|
## How the binary is managed
|
||||||
|
|
||||||
|
- `bun install` runs `scripts/postinstall.sh`, which downloads `tronbyt-server-linux-arm64` into `bin/` if it doesn't already exist
|
||||||
|
- At runtime, the app looks for a binary in `bin/` matching the current platform (`tronbyt-server-{darwin|linux}-{arm64|amd64}`)
|
||||||
|
- If the binary isn't found, the app logs an error with the download URL and the expected filename
|
||||||
|
- The binary runs as a child process with `stdout`/`stderr` inherited (logs show up in toes)
|
||||||
|
- On shutdown (SIGTERM from toes), the app forwards SIGTERM to the Go process
|
||||||
|
- The unix socket is created in `DATA_DIR/tronbyt.sock` and cleaned up on startup to handle stale sockets from crashes
|
||||||
0
bin/.gitkeep
Normal file
0
bin/.gitkeep
Normal file
35
bun.lock
Normal file
35
bun.lock
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "tronbyt",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "0.0.6",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
141
docs/PLAN.md
Normal file
141
docs/PLAN.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Tronbyt Toes App — Implementation Plan
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
A toes app that wraps the Tronbyt Go server (self-hosted Tidbyt replacement) as a managed subprocess. Bun proxies all HTTP and WebSocket traffic to the Go binary over a unix socket. The Tidbyt device talks to `tronbyt.toes.local` and everything chains through.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tidbyt device
|
||||||
|
→ tronbyt.toes.local (toes proxy)
|
||||||
|
→ Bun app on PORT (this app)
|
||||||
|
→ Go binary on unix socket
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tronbyt/
|
||||||
|
package.json
|
||||||
|
index.tsx # re-exports src/server
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
index.tsx # Hype app: spawns Go binary, proxies all traffic
|
||||||
|
pages/
|
||||||
|
index.tsx # Hype page (not needed, Go serves its own UI)
|
||||||
|
bin/
|
||||||
|
.gitkeep
|
||||||
|
tronbyt-server # pre-built binary (gitignored, per-platform)
|
||||||
|
docs/
|
||||||
|
PLAN.md # this file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### 1. Binary setup
|
||||||
|
|
||||||
|
- Download the right binary from https://github.com/tronbyt/server/releases
|
||||||
|
- Mac dev: `tronbyt-server-darwin-arm64`
|
||||||
|
- Pi production: `tronbyt-server-linux-arm64`
|
||||||
|
- Place in `bin/tronbyt-server`, make executable (`chmod +x`)
|
||||||
|
- Add `bin/tronbyt-server` to `.gitignore`
|
||||||
|
- On macOS: System Settings → Privacy & Security → Allow Anyway
|
||||||
|
|
||||||
|
### 2. Spawn the Go binary
|
||||||
|
|
||||||
|
On server startup, spawn `bin/tronbyt-server` with these env vars:
|
||||||
|
|
||||||
|
| Env Var | Value | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `TRONBYT_UNIX_SOCKET` | `{DATA_DIR}/tronbyt.sock` | Avoids port conflicts, clean proxying |
|
||||||
|
| `DATA_DIR` | `{DATA_DIR}` | Persists DB, apps, firmware across deploys |
|
||||||
|
| `DB_DSN` | `{DATA_DIR}/tronbyt.db` | Explicit DB path |
|
||||||
|
| `PRODUCTION` | `false` | Skip firmware downloads (set `true` on Pi later) |
|
||||||
|
| `SINGLE_USER_AUTO_LOGIN` | `true` | Home network, no login needed |
|
||||||
|
| `SYSTEM_APPS_AUTO_REFRESH` | `true` | Keep community apps updated |
|
||||||
|
|
||||||
|
Wait for health by polling the unix socket at `/health` until it returns 200.
|
||||||
|
|
||||||
|
### 3. HTTP proxy
|
||||||
|
|
||||||
|
Proxy all requests from Bun → Go over the unix socket:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.all('*', async (c) => {
|
||||||
|
const resp = await fetch(socketUrl + c.req.path + search, {
|
||||||
|
method: c.req.method,
|
||||||
|
headers: c.req.raw.headers,
|
||||||
|
body: c.req.raw.body,
|
||||||
|
unix: socketPath,
|
||||||
|
})
|
||||||
|
return new Response(resp.body, resp)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Bun's `fetch` supports `unix` option natively — no extra libraries needed.
|
||||||
|
|
||||||
|
### 4. WebSocket proxy
|
||||||
|
|
||||||
|
Two WS endpoints to proxy:
|
||||||
|
- `GET /ws` — dashboard real-time updates
|
||||||
|
- `GET /{deviceId}/ws` — device push connection
|
||||||
|
|
||||||
|
Use `Bun.serve`'s websocket support to accept the upgrade on the Bun side, then open a WebSocket to the Go server over the unix socket and relay messages bidirectionally.
|
||||||
|
|
||||||
|
### 5. Health check
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.get('/ok', async (c) => {
|
||||||
|
const resp = await fetch('http://localhost/health', { unix: socketPath })
|
||||||
|
return resp.ok ? c.text('ok') : c.text('unhealthy', 503)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Graceful shutdown
|
||||||
|
|
||||||
|
```ts
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
goProcess.kill('SIGTERM')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The Go server handles SIGTERM cleanly. Toes gives 10s before SIGKILL.
|
||||||
|
|
||||||
|
### 7. Remove Hype pages
|
||||||
|
|
||||||
|
The scaffolded `src/pages/index.tsx` isn't needed — the Go server serves its own web UI. The Bun app is purely a proxy, no HTML rendering. Can simplify to just `src/server/index.tsx` or even a single `index.ts`.
|
||||||
|
|
||||||
|
## Toes Environment Variables
|
||||||
|
|
||||||
|
These are set automatically by toes on every app:
|
||||||
|
|
||||||
|
| Var | Description |
|
||||||
|
|---|---|
|
||||||
|
| `PORT` | Port to listen on |
|
||||||
|
| `DATA_DIR` | Per-app persistent data directory |
|
||||||
|
| `APPS_DIR` | Shared apps directory |
|
||||||
|
| `TOES_URL` | Base URL of toes server |
|
||||||
|
| `APP_URL` | This app's subdomain URL |
|
||||||
|
|
||||||
|
## Device Configuration
|
||||||
|
|
||||||
|
Flash the Tidbyt with Tronbyt firmware, then set:
|
||||||
|
|
||||||
|
```
|
||||||
|
Image URL: http://tronbyt.toes.local/<device-id>/next
|
||||||
|
```
|
||||||
|
|
||||||
|
The device ID is assigned by the Tronbyt server when you add the device in its web UI.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
1. **IPv6 binding** — Not an issue with unix socket approach (no TCP binding on Go side)
|
||||||
|
2. **First boot is slow** — Community apps repo clones on first start (~15s). With `PRODUCTION=false`, firmware download is skipped. Set `PRODUCTION=true` on Pi if you want OTA firmware updates.
|
||||||
|
3. **macOS unsigned binary** — System Settings → Privacy & Security → Allow Anyway
|
||||||
|
4. **Device WiFi** — Tidbyt ESP32 is 2.4GHz only
|
||||||
|
5. **Image URL must be full path** — `http://tronbyt.toes.local/<device-id>/next`, not just the base URL
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
No new dependencies needed beyond what's already in package.json. Bun's native `fetch` handles unix sockets. The Go binary is self-contained (Pixlet embedded).
|
||||||
|
|
||||||
|
Can remove `@because/forge` and `@because/howl` from dependencies since this app has no UI of its own.
|
||||||
13
package.json
13
package.json
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "tronbyt",
|
"name": "tronbyt",
|
||||||
"module": "index.tsx",
|
"module": "src/server.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"toes": "bun run --watch index.tsx",
|
"toes": "bun run --watch src/server.ts",
|
||||||
"start": "bun toes",
|
"start": "bun toes",
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot src/server.ts",
|
||||||
|
"postinstall": "bash scripts/postinstall.sh"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"icon": "🖥️"
|
"icon": "🖥️"
|
||||||
|
|
@ -17,9 +18,5 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {}
|
||||||
"@because/hype": "0.0.6",
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/howl": "*"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
scripts/postinstall.sh
Executable file
17
scripts/postinstall.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BIN_DIR="$(dirname "$0")/../bin"
|
||||||
|
BINARY="$BIN_DIR/tronbyt-server-linux-arm64"
|
||||||
|
URL="https://github.com/tronbyt/server/releases/latest/download/tronbyt-server-linux-arm64"
|
||||||
|
|
||||||
|
if [ -f "$BINARY" ]; then
|
||||||
|
echo "tronbyt binary already exists, skipping download"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading tronbyt server binary..."
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
curl -L -o "$BINARY" "$URL"
|
||||||
|
chmod +x "$BINARY"
|
||||||
|
echo "Done"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export default () => <h1>tronbyt</h1>
|
|
||||||
159
src/server.ts
Normal file
159
src/server.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { join } from 'path'
|
||||||
|
import { 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 = 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 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 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()
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype({ok: true})
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
Loading…
Reference in New Issue
Block a user