From 5aca98fc58f3ce4f5357275e3a960a660476e57d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 27 Jan 2026 21:42:33 -0800 Subject: [PATCH] ssr => spa --- CLAUDE.md | 2 +- src/client/index.tsx | 383 +++++++++++++++++++++++++++++++++++++++++++ src/pages/index.tsx | 142 +--------------- src/server/apps.ts | 15 ++ src/server/index.tsx | 78 ++++++--- src/server/shell.tsx | 14 ++ tsconfig.json | 2 +- 7 files changed, 474 insertions(+), 162 deletions(-) create mode 100644 src/client/index.tsx create mode 100644 src/server/shell.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d338bdc..58c033d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Personal web server framework that auto-discovers and runs multiple web apps on 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 running apps with links +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 diff --git a/src/client/index.tsx b/src/client/index.tsx new file mode 100644 index 0000000..1b3e530 --- /dev/null +++ b/src/client/index.tsx @@ -0,0 +1,383 @@ +import { render as renderApp } from 'hono/jsx/dom' +import { define, Styles } from 'forge' +import type { App, AppState } from '../shared/types' + +// UI state (survives re-renders) +let selectedApp: string | null = null + +// Server state (from SSE) +let apps: App[] = [] + +// Layout +const Layout = define('Layout', { + display: 'flex', + height: '100vh', + fontFamily: 'system-ui, -apple-system, sans-serif', + background: '#0a0a0a', + color: '#e5e5e5', +}) + +const Sidebar = define('Sidebar', { + width: 220, + borderRight: '1px solid #333', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, +}) + +const Logo = define('Logo', { + padding: '20px 16px', + fontSize: 20, + fontWeight: 'bold', + borderBottom: '1px solid #333', +}) + +const SectionLabel = define('SectionLabel', { + padding: '16px 16px 8px', + fontSize: 12, + fontWeight: 600, + color: '#666', + textTransform: 'uppercase', + letterSpacing: '0.05em', +}) + +const AppList = define('AppList', { + flex: 1, + overflow: 'auto', +}) + +const AppItem = define('AppItem', { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 16px', + color: '#999', + textDecoration: 'none', + fontSize: 14, + cursor: 'pointer', + selectors: { + '&:hover': { background: '#1a1a1a', color: '#e5e5e5' }, + }, + variants: { + selected: { background: '#1f1f1f', color: '#fff', fontWeight: 500 }, + }, +}) + +const StatusDot = define('StatusDot', { + width: 8, + height: 8, + borderRadius: '50%', + flexShrink: 0, + variants: { + state: { + invalid: { background: '#ef4444' }, + stopped: { background: '#666' }, + starting: { background: '#eab308' }, + running: { background: '#22c55e' }, + stopping: { background: '#eab308' }, + }, + }, +}) + +const SidebarFooter = define('SidebarFooter', { + padding: 16, + borderTop: '1px solid #333', +}) + +const NewAppButton = define('NewAppButton', { + display: 'block', + padding: '8px 12px', + background: '#1f1f1f', + border: '1px solid #333', + borderRadius: 6, + color: '#999', + textDecoration: 'none', + fontSize: 14, + textAlign: 'center', + cursor: 'pointer', + selectors: { + '&:hover': { background: '#2a2a2a', color: '#e5e5e5' }, + }, +}) + +// Main pane +const Main = define('Main', { + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}) + +const MainHeader = define('MainHeader', { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 24px', + borderBottom: '1px solid #333', +}) + +const MainTitle = define('MainTitle', { + fontSize: 20, + fontWeight: 600, + margin: 0, +}) + +const HeaderActions = define('HeaderActions', { + display: 'flex', + gap: 8, +}) + +const MainContent = define('MainContent', { + flex: 1, + padding: 24, + overflow: 'auto', +}) + +const Section = define('Section', { + marginBottom: 32, +}) + +const SectionTitle = define('SectionTitle', { + fontSize: 12, + fontWeight: 600, + color: '#666', + textTransform: 'uppercase', + letterSpacing: '0.05em', + marginBottom: 12, + paddingBottom: 8, + borderBottom: '1px solid #333', +}) + +const InfoRow = define('InfoRow', { + display: 'flex', + alignItems: 'center', + gap: 12, + marginBottom: 12, + fontSize: 14, +}) + +const InfoLabel = define('InfoLabel', { + color: '#666', + width: 80, + flexShrink: 0, +}) + +const InfoValue = define('InfoValue', { + color: '#e5e5e5', + display: 'flex', + alignItems: 'center', + gap: 8, +}) + +const Link = define('Link', { + base: 'a', + color: '#22d3ee', + textDecoration: 'none', + selectors: { + '&:hover': { textDecoration: 'underline' }, + }, +}) + +const Button = define('Button', { + base: 'button', + padding: '6px 12px', + background: '#1f1f1f', + border: '1px solid #333', + borderRadius: 6, + color: '#e5e5e5', + fontSize: 13, + cursor: 'pointer', + selectors: { + '&:hover': { background: '#2a2a2a' }, + }, + variants: { + variant: { + danger: { borderColor: '#7f1d1d', color: '#fca5a5' }, + primary: { background: '#1d4ed8', borderColor: '#1d4ed8' }, + }, + }, +}) + +const ActionBar = define('ActionBar', { + display: 'flex', + gap: 8, + marginTop: 24, + paddingTop: 24, + borderTop: '1px solid #333', +}) + +const EmptyState = define('EmptyState', { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: '#666', + fontSize: 14, +}) + +const LogsContainer = define('LogsContainer', { + background: '#111', + borderRadius: 6, + padding: 12, + fontFamily: 'ui-monospace, monospace', + fontSize: 12, + color: '#888', + maxHeight: 200, + overflow: 'auto', +}) + +const LogLine = define('LogLine', { + marginBottom: 4, + selectors: { + '&:last-child': { marginBottom: 0 }, + }, +}) + +const LogTime = define('LogTime', { + color: '#555', + marginRight: 12, +}) + +const stateLabels: Record = { + invalid: 'Invalid', + stopped: 'Stopped', + starting: 'Starting', + running: 'Running', + stopping: 'Stopping', +} + +// Actions - call API then let SSE update the state +const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) +const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) +const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) + +const selectApp = (name: string) => { + selectedApp = name + render() +} + +const AppDetail = ({ app }: { app: App }) => ( + <> + + {app.name} + + + + + + +
+ Status + + State + + + {stateLabels[app.state]} + {app.port ? ` on :${app.port}` : ''} + + + {app.state === 'running' && app.port && ( + + URL + + + http://localhost:{app.port} + + + + )} + {app.started && ( + + Started + {new Date(app.started).toLocaleString()} + + )} + {app.state === 'invalid' && ( + + Error + + Missing or invalid package.json + + + )} +
+ +
+ Logs + + + --:--:-- + No logs yet + + +
+ + + {app.state === 'stopped' && ( + + )} + {app.state === 'running' && ( + <> + + + + )} + {(app.state === 'starting' || app.state === 'stopping') && ( + + )} + +
+ +) + +const Dashboard = () => { + const selected = apps.find(a => a.name === selectedApp) + + return ( + + + + 🐾 Toes + Apps + + {apps.map(app => ( + selectApp(app.name)} + selected={app.name === selectedApp ? true : undefined} + > + + {app.name} + + ))} + + + + New App + + +
+ {selected ? ( + + ) : ( + Select an app to view details + )} +
+
+ ) +} + +const render = () => { + renderApp(, document.getElementById('app')!) +} + +// SSE connection +const events = new EventSource('/api/apps/stream') +events.onmessage = e => { + apps = JSON.parse(e.data) + if (!selectedApp && apps.length) selectedApp = apps[0]!.name + render() +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e74d999..390202d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,141 +1,3 @@ -import { define, Styles } from 'forge' -import { allApps } from '../server/apps' -import type { AppState } from '../shared/types' +import { Shell } from '../server/shell' -const Apps = define('Apps', { - margin: '0 auto', - width: 750, - paddingTop: 20, -}) - -const color = '#00c0c9' -const hoverColor = 'magenta' - -const Link = define({ - base: 'a', - color, - textDecoration: 'none', - borderBottom: `1px solid ${color}`, - selectors: { - '&:hover': { - color: hoverColor, - cursor: 'pointer' - } - } -}) - -const AppCard = define('AppCard', { - marginBottom: 24, - padding: 16, - border: '1px solid #333', - borderRadius: 8, -}) - -const AppHeader = define('AppHeader', { - display: 'flex', - alignItems: 'center', - gap: 12, - marginBottom: 8, -}) - -const AppName = define('AppName', { - fontSize: 20, - fontWeight: 'bold', - margin: 0, -}) - -const State = define('State', { - fontSize: 14, - padding: '2px 8px', - borderRadius: 4, - - variants: { - status: { - invalid: { background: '#4a1c1c', color: '#f87171' }, - stopped: { background: '#3a3a3a', color: '#9ca3af' }, - starting: { background: '#3b3117', color: '#fbbf24' }, - running: { background: '#14532d', color: '#4ade80' }, - stopping: { background: '#3b3117', color: '#fbbf24' }, - } - } -}) - -const Info = define('Info', { - fontSize: 14, - color: '#9ca3af', - margin: '4px 0', -}) - -const ActionBar = define('ActionBar', { - marginTop: 12, - display: 'flex', - gap: 8, -}) - -const Button = define({ - base: 'button', - - selectors: { - 'form:has(>&)': { - display: 'inline' - } - }, - - render({ props, parts: { Root } }) { - if (!props.post) - return {props.children} - - return ( -
- {props.children} -
- ) - } -}) - -const stateLabels: Record = { - invalid: 'Invalid', - stopped: 'Stopped', - starting: 'Starting...', - running: 'Running', - stopping: 'Stopping...', -} - -export default () => ( - - -

🐾 Apps

- {allApps().map(app => ( - - - - {app.state === 'running' && app.port ? ( - {app.name} - ) : ( - app.name - )} - - {stateLabels[app.state]} - - - {app.port ? Port: {app.port} : null} - {app.started && Started: {new Date(app.started).toLocaleString()}} - - - {app.state === 'stopped' && ( - - )} - {app.state === 'running' && ( - <> - - - - )} - {app.state === 'invalid' && ( - Missing or invalid package.json - )} - - - ))} -
-) \ No newline at end of file +export default () => diff --git a/src/server/apps.ts b/src/server/apps.ts index 397f519..3864674 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -13,6 +13,14 @@ export type App = SharedApp & { const _apps = new Map() +// Change notification system +const _listeners = new Set<() => void>() +export const onChange = (cb: () => void) => { + _listeners.add(cb) + return () => _listeners.delete(cb) +} +const update = () => _listeners.forEach(cb => cb()) + const err = (app: string, ...msg: string[]) => console.error('🐾', `${app}:`, ...msg) @@ -88,6 +96,7 @@ const runApp = async (dir: string, port: number) => { // Set state to starting app.state = 'starting' app.port = port + update() const cwd = join(APPS_DIR, dir) @@ -109,6 +118,7 @@ const runApp = async (dir: string, port: number) => { app.state = 'running' app.proc = proc app.started = Date.now() + update() const streamOutput = async (stream: ReadableStream | null) => { if (!stream) return @@ -139,6 +149,7 @@ const runApp = async (dir: string, port: number) => { app.proc = undefined app.port = undefined app.started = undefined + update() }) } @@ -165,6 +176,7 @@ export const stopApp = (dir: string) => { info(dir, 'Stopping...') app.state = 'stopping' + update() app.proc?.kill() } @@ -179,6 +191,7 @@ const watchAppsDir = () => { if (!_apps.has(dir)) { const state: AppState = isApp(dir) ? 'stopped' : 'invalid' _apps.set(dir, { name: dir, state }) + update() if (state === 'stopped') { runApp(dir, getPort()) } @@ -207,9 +220,11 @@ const watchAppsDir = () => { // Update state if already stopped/invalid if (!valid && app.state === 'stopped') { app.state = 'invalid' + update() } if (valid && app.state === 'invalid') { app.state = 'stopped' + update() } }) } diff --git a/src/server/index.tsx b/src/server/index.tsx index 4791c14..46bea55 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,37 +1,75 @@ import { Hype } from 'hype' -import { initApps, startApp, stopApp } from './apps' +import { allApps, initApps, onChange, startApp, stopApp } from './apps' +import type { App as SharedApp } from '../shared/types' -const app = new Hype() +const app = new Hype({ layout: false }) console.log('🐾 Toes!') initApps() -app.post('/apps/:app/start', c => { - const app = c.req.param('app') - if (!app) return render404(c) +// SSE endpoint for real-time app state updates +app.get('/api/apps/stream', c => { + const encoder = new TextEncoder() - startApp(app) - return c.redirect('/') + const stream = new ReadableStream({ + start(controller) { + const send = () => { + // Strip proc field from apps before sending + const apps: SharedApp[] = allApps().map(({ name, state, port, started }) => ({ + name, + state, + port, + started, + })) + const data = JSON.stringify(apps) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + + // Send initial state + send() + + // Subscribe to changes + const unsub = onChange(send) + + // Handle client disconnect via abort signal + c.req.raw.signal.addEventListener('abort', () => { + unsub() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) }) -app.post('/apps/:app/restart', c => { - const app = c.req.param('app') - if (!app) return render404(c) +app.post('/api/apps/:app/start', c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App not found' }, 404) - stopApp(app) - startApp(app) - return c.redirect('/') + startApp(appName) + return c.json({ ok: true }) }) -app.post('/apps/:app/stop', c => { - const app = c.req.param('app') - if (!app) return render404(c) +app.post('/api/apps/:app/restart', c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App not found' }, 404) - stopApp(app) - return c.redirect('/') + stopApp(appName) + startApp(appName) + return c.json({ ok: true }) }) -const render404 = (c: any) => - c.text('404 Not Found', { status: 404 }) +app.post('/api/apps/:app/stop', c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App not found' }, 404) + + stopApp(appName) + return c.json({ ok: true }) +}) export default app.defaults diff --git a/src/server/shell.tsx b/src/server/shell.tsx new file mode 100644 index 0000000..bf9bf70 --- /dev/null +++ b/src/server/shell.tsx @@ -0,0 +1,14 @@ +export const Shell = () => ( + + + Toes + + +