diff --git a/CLAUDE.md b/CLAUDE.md
index 58c033d..104643b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,15 +1,18 @@
# Toes - Claude Code Guide
## What It Is
+
Personal web server framework that auto-discovers and runs multiple web apps on your home network. "Set it up, turn it on, forget about the cloud."
## How It Works
+
1. Host server scans `/apps` directory for valid apps
2. Valid app = has `package.json` with `scripts.toes` entry
3. Each app spawned as child process with unique port (3001+)
4. Dashboard UI shows all apps with current status, logs, and links
## Key Files
+
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
- `src/server/index.tsx` - Entry point (minimal, just initializes Hype)
- `src/pages/index.tsx` - Dashboard UI
@@ -17,32 +20,107 @@ Personal web server framework that auto-discovers and runs multiple web apps on
- `TODO.md` - User-maintained task list (read this!)
## Tech Stack
+
- **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
- TypeScript + Hono JSX
## Running
+
```bash
bun run --hot src/server/index.tsx # Dev mode with hot reload
```
## App Structure
+
```tsx
// apps/example/index.tsx
-import { Hype } from 'hype'
+import { Hype } from "hype"
const app = new Hype()
-app.get('/', c => c.html(
Content
))
+app.get("/", (c) => c.html(Content
))
export default app.defaults
```
## Conventions
+
- Apps get `PORT` env var from host
- Each app is isolated process with own dependencies
- No path-based routing - apps run on separate ports
- `DATA_DIR` env controls where apps are discovered
## Current State
+
- Core infrastructure: ✓ Complete (discovery, spawn, watch, ports, UI)
- Apps: basic, profile (working); risk, tictactoe (empty)
- Check TODO.md for planned features
+
+## 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 `export`s first, in alphabetical order.
+
+Then, after the `export`s (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:
+
+```ts
+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}`)
+}
+```
diff --git a/src/server/apps.ts b/src/server/apps.ts
index 10128bc..fedae6f 100644
--- a/src/server/apps.ts
+++ b/src/server/apps.ts
@@ -1,52 +1,99 @@
+import type { App as SharedApp, AppState, LogLine } from '@types'
import type { Subprocess } from 'bun'
-import {
- existsSync, readdirSync, readFileSync, writeFileSync,
- statSync, watch
-} from 'fs'
+import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs'
import { join } from 'path'
-import type { App as SharedApp, AppState, LogLine } from '../shared/types'
-export type { AppState } from '../shared/types'
+export type { AppState } from '@types'
+
+export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
const DEFAULT_EMOJI = '🖥️'
-const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
const MAX_LOGS = 100
+const _apps = new Map()
+const _listeners = new Set<() => void>()
+
+let NEXT_PORT = 3001
export type App = SharedApp & {
proc?: Subprocess
}
-const _apps = new Map()
+type LoadResult = { pkg: any; error?: string }
-// Change notification system
-const _listeners = new Set<() => void>()
-export const onChange = (cb: () => void) => {
+export const allApps = (): App[] =>
+ Array.from(_apps.values())
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+export const getApp = (dir: string): App | undefined =>
+ _apps.get(dir)
+
+export const runApps = () =>
+ allAppDirs().filter(isApp).forEach(startApp)
+
+export const runningApps = (): App[] =>
+ allApps().filter(a => a.state === 'running')
+
+export function initApps() {
+ discoverApps()
+ runApps()
+ watchAppsDir()
+}
+
+export function onChange(cb: () => void) {
_listeners.add(cb)
return () => _listeners.delete(cb)
}
-const update = () => _listeners.forEach(cb => cb())
+
+export function startApp(dir: string) {
+ const app = _apps.get(dir)
+ if (!app || app.state !== 'stopped') return
+ if (!isApp(dir)) return
+ runApp(dir, getPort())
+}
+
+export function stopApp(dir: string) {
+ const app = _apps.get(dir)
+ if (!app || app.state !== 'running') return
+
+ info(dir, 'Stopping...')
+ app.state = 'stopping'
+ update()
+ app.proc?.kill()
+}
+
+export function updateAppIcon(dir: string, icon: string) {
+ const { pkg, error } = loadApp(dir)
+ if (error) throw new Error(error)
+
+ pkg.toes ??= {}
+ pkg.toes.icon = icon
+ saveApp(dir, pkg)
+}
const err = (app: string, ...msg: string[]) =>
console.error('🐾', `${app}:`, ...msg)
+const getPort = () => NEXT_PORT++
+
const info = (app: string, ...msg: string[]) =>
console.log('🐾', `${app}:`, ...msg)
+const isApp = (dir: string): boolean =>
+ !loadApp(dir).error
+
const log = (app: string, ...msg: string[]) =>
console.log(`<${app}>`, ...msg)
-/** Returns all directory names in APPS_DIR */
-const allAppDirs = () => {
+const update = () => _listeners.forEach(cb => cb())
+
+function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort()
}
-let NEXT_PORT = 3001
-const getPort = () => NEXT_PORT++
-
-const discoverApps = () => {
+function discoverApps() {
for (const dir of allAppDirs()) {
const { pkg, error } = loadApp(dir)
const state: AppState = error ? 'invalid' : 'stopped'
@@ -55,12 +102,15 @@ const discoverApps = () => {
}
}
-export const runApps = () =>
- allAppDirs().filter(isApp).forEach(startApp)
+function isDir(path: string): boolean {
+ try {
+ return statSync(path).isDirectory()
+ } catch {
+ return false
+ }
+}
-type LoadResult = { pkg: any; error?: string }
-
-const loadApp = (dir: string): LoadResult => {
+function loadApp(dir: string): LoadResult {
try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
@@ -86,24 +136,7 @@ const loadApp = (dir: string): LoadResult => {
}
}
-const saveApp = (dir: string, pkg: any) => {
- const path = join(APPS_DIR, dir, 'package.json')
- writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
-}
-
-export const updateAppIcon = (dir: string, icon: string) => {
- const { pkg, error } = loadApp(dir)
- if (error) throw new Error(error)
-
- pkg.toes ??= {}
- pkg.toes.icon = icon
- saveApp(dir, pkg)
-}
-
-const isApp = (dir: string): boolean =>
- !loadApp(dir).error
-
-const runApp = async (dir: string, port: number) => {
+async function runApp(dir: string, port: number) {
const { pkg, error } = loadApp(dir)
if (error) return
@@ -175,35 +208,12 @@ const runApp = async (dir: string, port: number) => {
})
}
-/** Returns all apps */
-export const allApps = (): App[] =>
- Array.from(_apps.values())
- .sort((a, b) => a.name.localeCompare(b.name))
-
-/** Returns only running apps (for backwards compatibility) */
-export const runningApps = (): App[] =>
- allApps().filter(a => a.state === 'running')
-
-export const getApp = (dir: string): App | undefined => _apps.get(dir)
-
-export const startApp = (dir: string) => {
- const app = _apps.get(dir)
- if (!app || app.state !== 'stopped') return
- if (!isApp(dir)) return
- runApp(dir, getPort())
+function saveApp(dir: string, pkg: any) {
+ const path = join(APPS_DIR, dir, 'package.json')
+ writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
}
-export const stopApp = (dir: string) => {
- const app = _apps.get(dir)
- if (!app || app.state !== 'running') return
-
- info(dir, 'Stopping...')
- app.state = 'stopping'
- update()
- app.proc?.kill()
-}
-
-const watchAppsDir = () => {
+function watchAppsDir() {
watch(APPS_DIR, { recursive: true }, (event, filename) => {
if (!filename) return
@@ -265,17 +275,3 @@ const watchAppsDir = () => {
}
})
}
-
-function isDir(path: string): boolean {
- try {
- return statSync(path).isDirectory()
- } catch {
- return false
- }
-}
-
-export const initApps = () => {
- discoverApps()
- runApps()
- watchAppsDir()
-}
\ No newline at end of file
diff --git a/src/server/index.tsx b/src/server/index.tsx
index 90e2f5c..92e84cf 100644
--- a/src/server/index.tsx
+++ b/src/server/index.tsx
@@ -1,12 +1,9 @@
+import { allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from '$apps'
+import type { App as SharedApp } from '@types'
import { Hype } from 'hype'
-import { allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from './apps'
-import type { App as SharedApp } from '../shared/types'
const app = new Hype({ layout: false })
-console.log('🐾 Toes!')
-initApps()
-
// SSE endpoint for real-time app state updates
app.get('/api/apps/stream', c => {
const encoder = new TextEncoder()
@@ -98,4 +95,7 @@ app.post('/api/apps/:app/icon', c => {
}
})
+console.log('🐾 Toes!')
+initApps()
+
export default app.defaults
diff --git a/tsconfig.json b/tsconfig.json
index 16bb5e0..aa94996 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,30 +1,39 @@
{
"compilerOptions": {
// Environment setup & latest features
- "lib": ["ESNext", "DOM"],
+ "lib": [
+ "ESNext",
+ "DOM"
+ ],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
-
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
-
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
-
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
+ "noPropertyAccessFromIndexSignature": false,
+ "baseUrl": ".",
+ "paths": {
+ "$*": [
+ "./src/server/*"
+ ],
+ "@*": [
+ "./src/shared/*"
+ ]
+ }
}
-}
+}
\ No newline at end of file