new coding guidelines
This commit is contained in:
parent
2c8fff85f4
commit
2d544e9bd3
82
CLAUDE.md
82
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(<h1>Content</h1>))
|
||||
app.get("/", (c) => c.html(<h1>Content</h1>))
|
||||
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}`)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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<string, App>()
|
||||
const _listeners = new Set<() => void>()
|
||||
|
||||
let NEXT_PORT = 3001
|
||||
|
||||
export type App = SharedApp & {
|
||||
proc?: Subprocess
|
||||
}
|
||||
|
||||
const _apps = new Map<string, App>()
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user