Compare commits

...

2 Commits

Author SHA1 Message Date
048b0af34e gotcha 2026-01-27 21:20:01 -08:00
04de9541b7 down with the sickness 2026-01-27 21:18:47 -08:00
6 changed files with 250 additions and 52 deletions

View File

@ -11,7 +11,6 @@ Set it up, turn it on, and forget about the cloud.
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> : 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> </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,89 @@ 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.port = 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))
/** 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
runApp(dir, getPort())
}
export const stopApp = (dir: string) => {
const app = _apps.get(dir)
if (!app || app.state !== 'running') return
const stopApp = (dir: string) => {
const app = _runningApps.get(dir)
if (app) {
info(dir, 'Stopping...') info(dir, 'Stopping...')
app.proc.kill() 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
}