7.1 KiB
Toes
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
"Plug it in, turn it on, and forget about the cloud."
How It Works
- Server scans
APPS_DIRfor directories with apackage.jsoncontaining ascripts.toesentry - Each app is spawned as a child process with a unique port (3001-3100)
- Dashboard UI shows all apps with status, logs, and links via SSE
- CLI communicates with the server over HTTP
Tech Stack
- Bun runtime (not Node)
- Hype (custom HTTP framework wrapping Hono) from
@because/hype - Forge (typed CSS-in-JS) from
@because/forge - Commander + kleur for CLI
- TypeScript + Hono JSX
- Client renders with
hono/jsx/dom(no build step, served directly)
Running
bun run dev # Hot reload (deletes pub/client/index.js first)
bun run start # Production
bun run check # Type check
bun run test # Tests
Project Structure
src/
server/ # HTTP server and process management ($)
client/ # Browser-side dashboard
shared/ # Types shared between server and client (@)
lib/ # Code shared between CLI and server (%)
cli/ # CLI tool
tools/ # @because/toes package exports
pages/ # Hype page routes
Path aliases: $ = server, @ = shared, % = lib (defined in tsconfig.json).
Server (src/server/)
apps.ts-- The heart: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. ExportsAPPS_DIR,TOES_DIR,TOES_URL, and theApptype (extends sharedAppwith process/timer fields).api/apps.ts-- REST API + SSE stream. Routes:GET /(list),GET /stream(SSE),POST /:name/start|stop|restart,GET /:name/logs,POST /(create),DELETE /:name,PUT /:name/rename,PUT /:name/icon.api/sync.ts-- File sync protocol: manifest comparison, push/pull with hash-based diffing.index.tsx-- Entry point. Mounts API routers, tool URL redirects (/tool/:tool), tool API proxy (/api/tools/:tool/*), initializes apps.shell.tsx-- Minimal HTML shell for the SPA.tui.ts-- Terminal UI for the server process (renders app status table when TTY).
Client (src/client/)
Client-side SPA rendered with hono/jsx/dom. No build step -- Bun serves .tsx files directly.
index.tsx-- Entry point. Initializes rendering, SSE connection, theme, tool iframes.state.ts-- Mutable module-level state (apps,selectedApp,sidebarCollapsed, etc.) with localStorage persistence. Components import state directly.api.ts-- Fetch wrappers for server API calls.tool-iframes.ts-- Manages tool iframe lifecycle (caching, visibility, height communication).update.tsx-- SSE connection to/api/apps/streamfor real-time state updates.components/-- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.modals/-- NewApp, RenameApp, DeleteApp dialogs.styles/-- Forge CSS-in-JS (themes, buttons, forms, layout).themes/-- Light/dark theme token definitions.
CLI (src/cli/)
index.ts-- Entry point (#!/usr/bin/env bun).setup.ts-- Commander program definition with all commands.commands/-- Command implementations.http.ts-- HTTP client for talking to the toes server.name.ts-- App name resolution (argument or current directory).prompts.ts-- Interactive prompts.pager.ts-- Pipe output through system pager.
CLI commands:
- Apps:
list,info,new,get,open,rename,rm - Lifecycle:
start,stop,restart,logs,stats,cron - Sync:
push,pull,status,diff,sync,clean,stash - Config:
config,env,versions,history,rollback
Shared (src/shared/)
Types shared between browser and server. Cannot use Node/filesystem APIs (runs in browser).
types.ts--App,AppState,LogLine,Manifest,FileInfogitignore.ts--.toesignorepattern matching
Lib (src/lib/)
Server-side code shared between CLI and server. Can use Node APIs.
templates.ts-- Template generation fortoes new(bare, ssr, spa)sync.ts-- Manifest generation, hash computation
Tools Package (src/tools/)
The @because/toes package that apps/tools import. Published exports:
@because/toes-- re-exports from server (src/index.ts->src/server/sync.ts)@because/toes/tools--baseStyles,ToolScript,theme,loadAppEnv
Pages (src/pages/)
Hype page routes. index.tsx renders the Shell.
Key Concepts
App Lifecycle
States: invalid -> stopped <-> starting -> running -> stopping -> stopped
- Discovery: scan
APPS_DIR, read eachpackage.jsonforscripts.toes - Spawn:
Bun.spawn()withPORT,APPS_DIR,TOES_URL,TOES_DIR, plus per-app env vars - Health checks: every 30s to
/ok, 3 consecutive failures trigger restart - Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
Tools vs Apps
Tools are apps with "toes": { "tool": true } in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server: /tool/:tool?app=foo -> http://host:toolPort/?app=foo.
Versioning
Apps live at APPS_DIR/<name>/ with timestamped version directories and a current symlink. Push creates a new version; rollback moves the symlink.
Environment Variables
Per-app env files in TOES_DIR/env/:
_global.env-- shared by all apps<appname>.env-- per-app overrides
The server sets these on each app process: PORT, APPS_DIR, TOES_URL, TOES_DIR, DATA_DIR.
SSE Streaming
/api/apps/stream pushes the full app list on every state change. Client reconnects automatically. The onChange() callback system in apps.ts notifies listeners.
Coding Guidelines
TS files should be organized in the following way:
- imports
- re-exports
- const/lets
- enums
- interfaces
- types
- classes
- functions
- module init (top level function calls)
In each section, put the exports first, in alphabetical order.
Then, after the exports (if there were any), put everything else,
also in alphabetical order.
For single-line functions, use const fn = () => {} and put them in the
"functions" section of the file.
All other functions use the function blah(){} format.
Example:
import { code } from "coders"
import { something } from "somewhere"
export type { SomeType }
const RETRY_TIMES = 5
const WIDTH = 480
enum State {
Stopped,
Starting,
Running,
}
interface Config {
name: string
port: number
}
type Handler = (req: Request) => Response
class App {
config: Config
constructor(config: Config) {
this.config = config
}
}
const isApp = (name: string) =>
apps.has(name)
function createApp(name: string): App {
const app = new App({ name, port: 3000 })
apps.set(name, app)
return app
}
function start(app: App): void {
console.log(`Starting ${app.config.name}`)
}
Writing Apps and Tools
See docs/CLAUDE.md for the guide to writing toes apps and tools.