down with the sickness

This commit is contained in:
Chris Wanstrath 2026-01-27 21:18:47 -08:00
parent 4d176b18b8
commit 04de9541b7
6 changed files with 249 additions and 52 deletions

View File

@ -8,10 +8,9 @@ Set it up, turn it on, and forget about the cloud.
1. Plug in and turn on your Toes computer. 1. Plug in and turn on your Toes computer.
2. Tell Toes about your WiFi by <using dark @probablycorey magick>. 2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started! 3. Visit https://toes.local to get started!
## features ## features
- Hosts bun/hono/hype webapps - both SSR and SPA. - Hosts bun/hono/hype webapps - both SSR and SPA.
- `toes` CLI for pushing and pulling from your server. - `toes` CLI for pushing and pulling from your server.
- `toes` CLI for local dev mode. - `toes` CLI for local dev mode.

View File

@ -1,6 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "toes", "name": "toes",
@ -23,11 +23,11 @@
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"forge": ["forge@git+https://git.nose.space/defunkt/forge#9b6e1e91ec77d7e03589cac256d97fb9cd942184", { "peerDependencies": { "typescript": "^5" } }, "9b6e1e91ec77d7e03589cac256d97fb9cd942184"], "forge": ["forge@git+https://git.nose.space/defunkt/forge#debfd73ab22c50f66ccc93cb41164c234f78a920", { "peerDependencies": { "typescript": "^5" } }, "debfd73ab22c50f66ccc93cb41164c234f78a920"],
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"hype": ["hype@git+https://git.nose.space/defunkt/hype#7b9cade936c4897539d2ca14299d90f80deb6ebe", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "7b9cade936c4897539d2ca14299d90f80deb6ebe"], "hype": ["hype@git+https://git.nose.space/defunkt/hype#b9b4e205c9b04cb3897054db2940ff67ce7cfd5b", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "b9b4e205c9b04cb3897054db2940ff67ce7cfd5b"],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],

View File

@ -1,5 +1,6 @@
import { define, Styles } from 'forge' import { define, Styles } from 'forge'
import { runningApps } from '../server/apps' import { allApps } from '../server/apps'
import type { AppState } from '../shared/types'
const Apps = define('Apps', { const Apps = define('Apps', {
margin: '0 auto', margin: '0 auto',
@ -9,6 +10,7 @@ const Apps = define('Apps', {
const color = '#00c0c9' const color = '#00c0c9'
const hoverColor = 'magenta' const hoverColor = 'magenta'
const Link = define({ const Link = define({
base: 'a', base: 'a',
color, color,
@ -22,19 +24,118 @@ const Link = define({
} }
}) })
const Timestamp = define({ const AppCard = define('AppCard', {
fontSize: 18 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 () => ( export default () => (
<Apps> <Apps>
<Styles /> <Styles />
<h1>🐾 Running Apps</h1> <h1>🐾 Apps</h1>
{runningApps().map(app => ( {allApps().map(app => (
<h2> <AppCard>
<Link href={`http://localhost:${app.port}`}>{app.port}: {app.name}</Link> <AppHeader>
<Timestamp>Started: {new Date(app.started).toLocaleString()}</Timestamp> <AppName>
</h2> {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>}
{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> </Apps>
) )

View File

@ -1,17 +1,17 @@
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { existsSync, readdirSync, readFileSync, watch } from 'fs' import { existsSync, readdirSync, readFileSync, watch } from 'fs'
import { join } from 'path' import { join } from 'path'
import type { App as SharedApp, AppState } from '../shared/types'
export type { AppState } from '../shared/types'
const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
type RunningApp = { export type App = SharedApp & {
name: string proc?: Subprocess
port: number
started: number
proc: Subprocess
} }
const _runningApps = new Map<string, RunningApp>() const _apps = new Map<string, App>()
const err = (app: string, ...msg: string[]) => const err = (app: string, ...msg: string[]) =>
console.error('🐾', `${app}:`, ...msg) console.error('🐾', `${app}:`, ...msg)
@ -22,17 +22,29 @@ const info = (app: string, ...msg: string[]) =>
const log = (app: string, ...msg: string[]) => const log = (app: string, ...msg: string[]) =>
console.log(`<${app}>`, ...msg) console.log(`<${app}>`, ...msg)
export const appNames = () => { /** Returns all directory names in APPS_DIR */
const allAppDirs = () => {
return readdirSync(APPS_DIR, { withFileTypes: true }) return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name) .map(e => e.name)
.filter(isApp)
.sort() .sort()
} }
/** Returns names of valid apps (those with scripts.toes in package.json) */
export const appNames = () => allAppDirs().filter(isApp)
let NEXT_PORT = 3001 let NEXT_PORT = 3001
const getPort = () => NEXT_PORT++ const getPort = () => NEXT_PORT++
/** Discover all apps and set initial states */
const discoverApps = () => {
for (const dir of allAppDirs()) {
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
_apps.set(dir, { name: dir, state })
}
}
/** Start all valid apps */
export const runApps = () => { export const runApps = () => {
for (const dir of appNames()) { for (const dir of appNames()) {
const port = getPort() const port = getPort()
@ -70,6 +82,13 @@ const runApp = async (dir: string, port: number) => {
const pkg = loadApp(dir) const pkg = loadApp(dir)
if (!pkg.scripts?.toes) return if (!pkg.scripts?.toes) return
const app = _apps.get(dir)
if (!app) return
// Set state to starting
app.state = 'starting'
app.port = port
const cwd = join(APPS_DIR, dir) const cwd = join(APPS_DIR, dir)
const needsInstall = !existsSync(join(cwd, 'node_modules')) const needsInstall = !existsSync(join(cwd, 'node_modules'))
@ -86,14 +105,12 @@ const runApp = async (dir: string, port: number) => {
stderr: 'pipe', stderr: 'pipe',
}) })
_runningApps.set(dir, { // Set state to running
name: dir, app.state = 'running'
port, app.proc = proc
proc, app.started = Date.now()
started: Date.now()
})
const streamOutput = async (stream: ReadableStream<Uint8Array> | null, isErr: boolean) => { const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
if (!stream) return if (!stream) return
const reader = stream.getReader() const reader = stream.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
@ -102,14 +119,13 @@ const runApp = async (dir: string, port: number) => {
if (done) break if (done) break
const text = decoder.decode(value).trimEnd() const text = decoder.decode(value).trimEnd()
if (text) { if (text) {
//isErr ? err(dir, text) : info(dir, text)
log(dir, text) log(dir, text)
} }
} }
} }
streamOutput(proc.stdout, false) streamOutput(proc.stdout)
streamOutput(proc.stderr, true) streamOutput(proc.stderr)
// Handle process exit // Handle process exit
proc.exited.then(code => { proc.exited.then(code => {
@ -117,43 +133,88 @@ const runApp = async (dir: string, port: number) => {
err(dir, `Exited with code ${code}`) err(dir, `Exited with code ${code}`)
else else
info(dir, 'Stopped') info(dir, 'Stopped')
_runningApps.delete(dir)
// Reset to stopped state (or invalid if no longer valid)
app.state = isApp(dir) ? 'stopped' : 'invalid'
app.proc = undefined
app.started = undefined
}) })
} }
export const runningApps = (): RunningApp[] => /** Returns all apps */
Array.from(_runningApps.values()) export const allApps = (): App[] =>
.sort((a, b) => a.port - b.port) Array.from(_apps.values())
.sort((a, b) => a.name.localeCompare(b.name))
const stopApp = (dir: string) => { /** Returns only running apps (for backwards compatibility) */
const app = _runningApps.get(dir) export const runningApps = (): App[] =>
if (app) { allApps().filter(a => a.state === 'running')
info(dir, 'Stopping...')
app.proc.kill() 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
runApp(dir, getPort())
}
export const stopApp = (dir: string) => {
const app = _apps.get(dir)
if (!app || app.state !== 'running') return
info(dir, 'Stopping...')
app.state = 'stopping'
app.proc?.kill()
} }
const watchAppsDir = () => { const watchAppsDir = () => {
watch(APPS_DIR, { recursive: true }, (event, filename) => { watch(APPS_DIR, { recursive: true }, (_event, filename) => {
if (!filename) return if (!filename) return
// Only care about package.json changes
if (!filename.endsWith('package.json')) return
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp") // Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp")
const dir = filename.split('/')[0]! const dir = filename.split('/')[0]!
if (isApp(dir) && !_runningApps.has(dir)) { // Handle new directory appearing
const port = getPort() if (!_apps.has(dir)) {
runApp(dir, port) const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
_apps.set(dir, { name: dir, state })
if (state === 'stopped') {
runApp(dir, getPort())
}
return
} }
if (_runningApps.has(dir) && !isApp(dir)) const app = _apps.get(dir)!
stopApp(dir)
// Only care about package.json changes for existing apps
if (!filename.endsWith('package.json')) return
const valid = isApp(dir)
// App became valid - start it if stopped
if (valid && app.state === 'invalid') {
app.state = 'stopped'
runApp(dir, getPort())
}
// App became invalid - stop it if running
if (!valid && app.state === 'running') {
app.state = 'invalid'
app.proc?.kill()
}
// Update state if already stopped/invalid
if (!valid && app.state === 'stopped') {
app.state = 'invalid'
}
if (valid && app.state === 'invalid') {
app.state = 'stopped'
}
}) })
} }
export const initApps = () => { export const initApps = () => {
discoverApps()
runApps() runApps()
watchAppsDir() watchAppsDir()
} }

View File

@ -1,9 +1,37 @@
import { Hype } from 'hype' import { Hype } from 'hype'
import { initApps } from './apps' import { initApps, startApp, stopApp } from './apps'
const app = new Hype() const app = new Hype()
console.log('🐾 Toes!') console.log('🐾 Toes!')
initApps() initApps()
app.post('/apps/:app/start', c => {
const app = c.req.param('app')
if (!app) return render404(c)
startApp(app)
return c.redirect('/')
})
app.post('/apps/:app/restart', c => {
const app = c.req.param('app')
if (!app) return render404(c)
stopApp(app)
startApp(app)
return c.redirect('/')
})
app.post('/apps/:app/stop', c => {
const app = c.req.param('app')
if (!app) return render404(c)
stopApp(app)
return c.redirect('/')
})
const render404 = (c: any) =>
c.text('404 Not Found', { status: 404 })
export default app.defaults export default app.defaults

8
src/shared/types.ts Normal file
View File

@ -0,0 +1,8 @@
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
export type App = {
name: string
state: AppState
port?: number
started?: number
}