diff --git a/CLAUDE.md b/CLAUDE.md index 424faf8..b826cf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,10 +48,16 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json). - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with 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 via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management. -- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE. -- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps. +- `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping. +- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE. +- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart. +- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps. +- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff. +- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing. - `shell.tsx` -- Minimal HTML shell for the SPA. -- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY). +- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib). +- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms. +- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle. ### Client (`src/client/`) @@ -62,9 +68,11 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` - `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/stream` for real-time state updates. -- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection. +- `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs). +- `ansi.ts` -- ANSI escape code handling for log rendering. +- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal. - `modals/` -- NewApp, RenameApp, DeleteApp dialogs. -- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout). +- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc). - `themes/` -- Light/dark theme token definitions. ### CLI (`src/cli/`) @@ -78,30 +86,37 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` - `pager.ts` -- Pipe output through system pager. CLI commands: -- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm` -- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare` -- **Config**: `env` +- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm` +- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run) +- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing) +- **Hidden**: `list`, `info`, `log`, `version`, `shell` + +Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`). ### Shared (`src/shared/`) Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser). -- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo` +- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME` +- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions +- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction - `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers) ### Lib (`src/lib/`) Server-side code shared between CLI and server. Can use Node APIs. -- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa) +- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants +- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts` +- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`) - `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools) ### 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` +- `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`) +- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME` ### Pages (`src/pages/`) @@ -111,17 +126,25 @@ Hype page routes. `index.tsx` renders the Shell. ### App Lifecycle -States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped` +States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped` - Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes` -- Spawn: `Bun.spawn()` with `PORT`, `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 +- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()` +- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout) +- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart +- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts. - Graceful shutdown: SIGTERM with 10s timeout before SIGKILL +- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes + +### Subdomain Proxy + +Every app gets a subdomain: `.localhost` (dev) or `.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL. ### 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`. +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 via subdomain: `/tool/:tool?app=foo` -> `http://.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:/*`. + +The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page. ### Versioning @@ -133,14 +156,14 @@ Per-app env files in `TOES_DIR/env/`: - `_global.env` -- shared by all apps - `.env` -- per-app overrides -The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`. +The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`. ### SSE Streaming Two SSE endpoints serve different consumers: - `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`. -- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:activate`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`. +- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`. ## Coding Guidelines diff --git a/src/server/apps.ts b/src/server/apps.ts index 6c350ce..1847305 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -159,11 +159,14 @@ export function registerApp(dir: string) { if (_apps.has(dir)) return // Already registered const { pkg, error } = loadApp(dir) - const state: AppState = error ? 'invalid' : 'stopped' - _apps.set(dir, buildApp(dir, pkg, state, error)) + const isStatic = !!pkg.toes?.static + const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped') + const app = buildApp(dir, pkg, state, error) + if (isStatic) app.started = Date.now() + _apps.set(dir, app) update() emit({ type: 'app:create', app: dir }) - if (!error) { + if (!error && !isStatic) { runApp(dir, getPort(dir)) } } @@ -227,6 +230,17 @@ export async function renameApp(oldName: string, newName: string): Promise<{ ok: export function startApp(dir: string) { const app = _apps.get(dir) if (!app || (app.state !== 'stopped' && app.state !== 'invalid' && app.state !== 'error')) return + + // Static apps don't need a process + if (app.static) { + app.state = 'running' + app.started = Date.now() + app.manuallyStopped = false + update() + emit({ type: 'app:start', app: dir }) + return + } + if (!isApp(dir)) return // Clear flags when explicitly starting @@ -240,6 +254,14 @@ export async function restartApp(dir: string): Promise { const app = _apps.get(dir) if (!app) return + // Static apps just ensure running state + if (app.static) { + app.state = 'running' + app.started = Date.now() + update() + return + } + // Stop if running if (app.state === 'running' || app.state === 'starting') { stopApp(dir) @@ -267,6 +289,16 @@ export function stopApp(dir: string) { const app = _apps.get(dir) if (!app || app.state !== 'running') return + // Static apps just toggle state + if (app.static) { + app.state = 'stopped' + app.started = undefined + app.manuallyStopped = true + update() + emit({ type: 'app:stop', app: dir }) + return + } + info(app, 'Stopping...') app.state = 'stopping' app.manuallyStopped = true @@ -306,6 +338,7 @@ const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App = apps: pkg.toes?.apps, dashboard: pkg.toes?.dashboard, share: pkg.toes?.share, + static: pkg.toes?.static, }) const clearTimers = (app: App) => { @@ -355,8 +388,11 @@ function allAppDirs() { function discoverApps() { for (const dir of allAppDirs()) { const { pkg, error } = loadApp(dir) - const state: AppState = error ? 'invalid' : 'stopped' - _apps.set(dir, buildApp(dir, pkg, state, error)) + const isStatic = !!pkg.toes?.static + const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped') + const app = buildApp(dir, pkg, state, error) + if (isStatic) app.started = Date.now() + _apps.set(dir, app) } update() } @@ -542,6 +578,8 @@ function loadApp(dir: string): LoadResult { if (json.scripts?.toes) { return { pkg: json } + } else if (hasPublicDir(dir)) { + return { pkg: { ...json, toes: { ...json.toes, static: true } } } } else { return { pkg: json, error: 'Missing scripts.toes in package.json' } } @@ -550,10 +588,17 @@ function loadApp(dir: string): LoadResult { return { pkg: {}, error } } } catch (e) { + // No package.json — check for pub/ directory (static site) + if (hasPublicDir(dir)) { + return { pkg: { toes: { static: true, icon: '📄' } } } + } return { pkg: {}, error: 'Missing package.json' } } } +const hasPublicDir = (dir: string): boolean => + existsSync(join(APPS_DIR, dir, 'pub')) + function maybeResetBackoff(app: App) { if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) { diff --git a/src/server/proxy.ts b/src/server/proxy.ts index d4be98f..83c3468 100644 --- a/src/server/proxy.ts +++ b/src/server/proxy.ts @@ -1,5 +1,6 @@ import type { Server, ServerWebSocket } from 'bun' import { getAppBySubdomain } from '$apps' +import { serveStatic } from '$static' export const perf = { timing: false } @@ -38,7 +39,16 @@ export function extractSubdomain(host: string): string | null { export async function proxySubdomain(subdomain: string, req: Request): Promise { const app = getAppBySubdomain(subdomain) - if (!app || app.state !== 'running' || !app.port) { + if (!app || app.state !== 'running') { + return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) + } + + // Static apps: serve from pub/ directory + if (app.static) { + return serveStatic(app.name, req) + } + + if (!app.port) { return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) } diff --git a/src/server/static.ts b/src/server/static.ts new file mode 100644 index 0000000..208897d --- /dev/null +++ b/src/server/static.ts @@ -0,0 +1,92 @@ +import { APPS_DIR } from '$apps' +import { existsSync, readdirSync, statSync } from 'fs' +import { join } from 'path' + +export async function serveStatic(appName: string, req: Request): Promise { + const url = new URL(req.url) + const pathname = decodeURIComponent(url.pathname) + const pubDir = join(APPS_DIR, appName, 'pub') + + // Resolve the file path, preventing directory traversal + const filePath = join(pubDir, pathname) + if (!filePath.startsWith(pubDir)) { + return new Response('Forbidden', { status: 403 }) + } + + // Directory: try index.html, then file listing + if (existsSync(filePath) && statSync(filePath).isDirectory()) { + const indexPath = join(filePath, 'index.html') + if (existsSync(indexPath)) { + return new Response(Bun.file(indexPath)) + } + return fileListing(appName, pathname, filePath) + } + + // Exact file match + if (existsSync(filePath)) { + return new Response(Bun.file(filePath)) + } + + // Clean URLs: try .html extension + const htmlPath = filePath + '.html' + if (existsSync(htmlPath)) { + return new Response(Bun.file(htmlPath)) + } + + return new Response('Not Found', { status: 404 }) +} + +function fileListing(appName: string, pathname: string, dirPath: string): Response { + const trail = pathname.endsWith('/') ? pathname : pathname + '/' + + const entries = readdirSync(dirPath, { withFileTypes: true }) + .filter(e => !e.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1 + if (!a.isDirectory() && b.isDirectory()) return 1 + return a.name.localeCompare(b.name) + }) + + const rows = entries.map(e => { + const display = e.isDirectory() ? `${e.name}/` : e.name + const href = `${trail}${e.name}` + const stat = statSync(join(dirPath, e.name)) + const size = e.isDirectory() ? '—' : formatSize(stat.size) + return ` ${display}${size}` + }).join('\n') + + const parent = pathname !== '/' + ? ` ..\n` + : '' + + const html = ` + + + + + ${appName} — ${pathname} + + + +

${appName}${pathname}

+ +${parent}${rows} +
+ +` + + return new Response(html, { headers: { 'content-type': 'text/html' } }) +} + +const formatSize = (bytes: number): string => + bytes < 1024 ? `${bytes} B` + : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` + : `${(bytes / (1024 * 1024)).toFixed(1)} MB` diff --git a/src/shared/types.ts b/src/shared/types.ts index 43f8fb9..1b3e10b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -32,6 +32,7 @@ export type App = { apps?: boolean dashboard?: boolean share?: boolean + static?: boolean tunnelEnabled?: boolean tunnelUrl?: string }