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