ssr => spa

This commit is contained in:
Chris Wanstrath 2026-01-27 21:42:33 -08:00
parent 048b0af34e
commit 5aca98fc58
7 changed files with 474 additions and 162 deletions

View File

@ -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 1. Host server scans `/apps` directory for valid apps
2. Valid app = has `package.json` with `scripts.toes` entry 2. Valid app = has `package.json` with `scripts.toes` entry
3. Each app spawned as child process with unique port (3001+) 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 ## Key Files
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle - `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle

383
src/client/index.tsx Normal file
View File

@ -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<AppState, string> = {
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 }) => (
<>
<MainHeader>
<MainTitle>{app.name}</MainTitle>
<HeaderActions>
<Button>Settings</Button>
<Button variant="danger">Delete</Button>
</HeaderActions>
</MainHeader>
<MainContent>
<Section>
<SectionTitle>Status</SectionTitle>
<InfoRow>
<InfoLabel>State</InfoLabel>
<InfoValue>
<StatusDot state={app.state} />
{stateLabels[app.state]}
{app.port ? ` on :${app.port}` : ''}
</InfoValue>
</InfoRow>
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>URL</InfoLabel>
<InfoValue>
<Link href={`http://localhost:${app.port}`} target="_blank">
http://localhost:{app.port}
</Link>
</InfoValue>
</InfoRow>
)}
{app.started && (
<InfoRow>
<InfoLabel>Started</InfoLabel>
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
</InfoRow>
)}
{app.state === 'invalid' && (
<InfoRow>
<InfoLabel>Error</InfoLabel>
<InfoValue style={{ color: '#f87171' }}>
Missing or invalid package.json
</InfoValue>
</InfoRow>
)}
</Section>
<Section>
<SectionTitle>Logs</SectionTitle>
<LogsContainer>
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: '#666' }}>No logs yet</span>
</LogLine>
</LogsContainer>
</Section>
<ActionBar>
{app.state === 'stopped' && (
<Button variant="primary" onClick={() => startApp(app.name)}>
Start
</Button>
)}
{app.state === 'running' && (
<>
<Button onClick={() => restartApp(app.name)}>Restart</Button>
<Button variant="danger" onClick={() => stopApp(app.name)}>
Stop
</Button>
</>
)}
{(app.state === 'starting' || app.state === 'stopping') && (
<Button disabled>{stateLabels[app.state]}...</Button>
)}
</ActionBar>
</MainContent>
</>
)
const Dashboard = () => {
const selected = apps.find(a => a.name === selectedApp)
return (
<Layout>
<Styles />
<Sidebar>
<Logo>🐾 Toes</Logo>
<SectionLabel>Apps</SectionLabel>
<AppList>
{apps.map(app => (
<AppItem
key={app.name}
onClick={() => selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined}
>
<StatusDot state={app.state} />
{app.name}
</AppItem>
))}
</AppList>
<SidebarFooter>
<NewAppButton>+ New App</NewAppButton>
</SidebarFooter>
</Sidebar>
<Main>
{selected ? (
<AppDetail app={selected} />
) : (
<EmptyState>Select an app to view details</EmptyState>
)}
</Main>
</Layout>
)
}
const render = () => {
renderApp(<Dashboard />, 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()
}

View File

@ -1,141 +1,3 @@
import { define, Styles } from 'forge' import { Shell } from '../server/shell'
import { allApps } from '../server/apps'
import type { AppState } from '../shared/types'
const Apps = define('Apps', { export default () => <Shell />
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 <Root>{props.children}</Root>
return (
<form method='post' action={props.post}>
<Root onClick="this.closest('form').submit()">{props.children}</Root>
</form>
)
}
})
const stateLabels: Record<AppState, string> = {
invalid: 'Invalid',
stopped: 'Stopped',
starting: 'Starting...',
running: 'Running',
stopping: 'Stopping...',
}
export default () => (
<Apps>
<Styles />
<h1>🐾 Apps</h1>
{allApps().map(app => (
<AppCard>
<AppHeader>
<AppName>
{app.state === 'running' && app.port ? (
<Link href={`http://localhost:${app.port}`}>{app.name}</Link>
) : (
app.name
)}
</AppName>
<State status={app.state}>{stateLabels[app.state]}</State>
</AppHeader>
{app.port ? <Info>Port: {app.port}</Info> : null}
{app.started && <Info>Started: {new Date(app.started).toLocaleString()}</Info>}
<ActionBar>
{app.state === 'stopped' && (
<Button post={`/apps/${app.name}/start`}>Start</Button>
)}
{app.state === 'running' && (
<>
<Button post={`/apps/${app.name}/stop`}>Stop</Button>
<Button post={`/apps/${app.name}/restart`}>Restart</Button>
</>
)}
{app.state === 'invalid' && (
<Info>Missing or invalid package.json</Info>
)}
</ActionBar>
</AppCard>
))}
</Apps>
)

View File

@ -13,6 +13,14 @@ export type App = SharedApp & {
const _apps = new Map<string, App>() const _apps = new Map<string, App>()
// 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[]) => const err = (app: string, ...msg: string[]) =>
console.error('🐾', `${app}:`, ...msg) console.error('🐾', `${app}:`, ...msg)
@ -88,6 +96,7 @@ const runApp = async (dir: string, port: number) => {
// Set state to starting // Set state to starting
app.state = 'starting' app.state = 'starting'
app.port = port app.port = port
update()
const cwd = join(APPS_DIR, dir) const cwd = join(APPS_DIR, dir)
@ -109,6 +118,7 @@ const runApp = async (dir: string, port: number) => {
app.state = 'running' app.state = 'running'
app.proc = proc app.proc = proc
app.started = Date.now() app.started = Date.now()
update()
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => { const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
if (!stream) return if (!stream) return
@ -139,6 +149,7 @@ const runApp = async (dir: string, port: number) => {
app.proc = undefined app.proc = undefined
app.port = undefined app.port = undefined
app.started = undefined app.started = undefined
update()
}) })
} }
@ -165,6 +176,7 @@ export const stopApp = (dir: string) => {
info(dir, 'Stopping...') info(dir, 'Stopping...')
app.state = 'stopping' app.state = 'stopping'
update()
app.proc?.kill() app.proc?.kill()
} }
@ -179,6 +191,7 @@ const watchAppsDir = () => {
if (!_apps.has(dir)) { if (!_apps.has(dir)) {
const state: AppState = isApp(dir) ? 'stopped' : 'invalid' const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
_apps.set(dir, { name: dir, state }) _apps.set(dir, { name: dir, state })
update()
if (state === 'stopped') { if (state === 'stopped') {
runApp(dir, getPort()) runApp(dir, getPort())
} }
@ -207,9 +220,11 @@ const watchAppsDir = () => {
// Update state if already stopped/invalid // Update state if already stopped/invalid
if (!valid && app.state === 'stopped') { if (!valid && app.state === 'stopped') {
app.state = 'invalid' app.state = 'invalid'
update()
} }
if (valid && app.state === 'invalid') { if (valid && app.state === 'invalid') {
app.state = 'stopped' app.state = 'stopped'
update()
} }
}) })
} }

View File

@ -1,37 +1,75 @@
import { Hype } from 'hype' 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!') console.log('🐾 Toes!')
initApps() initApps()
app.post('/apps/:app/start', c => { // SSE endpoint for real-time app state updates
const app = c.req.param('app') app.get('/api/apps/stream', c => {
if (!app) return render404(c) const encoder = new TextEncoder()
startApp(app) const stream = new ReadableStream({
return c.redirect('/') 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 => { app.post('/api/apps/:app/start', c => {
const app = c.req.param('app') const appName = c.req.param('app')
if (!app) return render404(c) if (!appName) return c.json({ error: 'App not found' }, 404)
stopApp(app) startApp(appName)
startApp(app) return c.json({ ok: true })
return c.redirect('/')
}) })
app.post('/apps/:app/stop', c => { app.post('/api/apps/:app/restart', c => {
const app = c.req.param('app') const appName = c.req.param('app')
if (!app) return render404(c) if (!appName) return c.json({ error: 'App not found' }, 404)
stopApp(app) stopApp(appName)
return c.redirect('/') startApp(appName)
return c.json({ ok: true })
}) })
const render404 = (c: any) => app.post('/api/apps/:app/stop', c => {
c.text('404 Not Found', { status: 404 }) 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 export default app.defaults

14
src/server/shell.tsx Normal file
View File

@ -0,0 +1,14 @@
export const Shell = () => (
<html>
<head>
<title>Toes</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />
</head>
<body>
<div id="app"></div>
<script type="module" src="/client/index.js"></script>
</body>
</html>
)

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext", "DOM"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",