diff --git a/README.md b/README.md index 96e8364..b53eed2 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,9 @@ Set it up, turn it on, and forget about the cloud. 1. Plug in and turn on your Toes computer. 2. Tell Toes about your WiFi by . -3. Visit https://toes.local to get started! +3. Visit https://toes.local to get started! ## features - - Hosts bun/hono/hype webapps - both SSR and SPA. - `toes` CLI for pushing and pulling from your server. - `toes` CLI for local dev mode. diff --git a/bun.lock b/bun.lock index 317b600..9e1e7d9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "toes", @@ -23,11 +23,11 @@ "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=="], - "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=="], diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 860bdce..542679d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,5 +1,6 @@ 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', { margin: '0 auto', @@ -9,6 +10,7 @@ const Apps = define('Apps', { const color = '#00c0c9' const hoverColor = 'magenta' + const Link = define({ base: 'a', color, @@ -22,19 +24,118 @@ const Link = define({ } }) -const Timestamp = define({ - fontSize: 18 +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 () => ( -

🐾 Running Apps

- {runningApps().map(app => ( -

- {app.port}: {app.name} - Started: {new Date(app.started).toLocaleString()} -

+

🐾 Apps

+ {allApps().map(app => ( + + + + {app.state === 'running' && app.port ? ( + {app.name} + ) : ( + app.name + )} + + {stateLabels[app.state]} + + + {app.port && Port: {app.port}} + {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 diff --git a/src/server/apps.ts b/src/server/apps.ts index 5e290b5..e5de418 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -1,17 +1,17 @@ import type { Subprocess } from 'bun' import { existsSync, readdirSync, readFileSync, watch } from 'fs' 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') -type RunningApp = { - name: string - port: number - started: number - proc: Subprocess +export type App = SharedApp & { + proc?: Subprocess } -const _runningApps = new Map() +const _apps = new Map() const err = (app: string, ...msg: string[]) => console.error('🐾', `${app}:`, ...msg) @@ -22,17 +22,29 @@ const info = (app: string, ...msg: string[]) => const log = (app: string, ...msg: string[]) => console.log(`<${app}>`, ...msg) -export const appNames = () => { +/** Returns all directory names in APPS_DIR */ +const allAppDirs = () => { return readdirSync(APPS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) - .filter(isApp) .sort() } +/** Returns names of valid apps (those with scripts.toes in package.json) */ +export const appNames = () => allAppDirs().filter(isApp) + let NEXT_PORT = 3001 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 = () => { for (const dir of appNames()) { const port = getPort() @@ -70,6 +82,13 @@ const runApp = async (dir: string, port: number) => { const pkg = loadApp(dir) 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 needsInstall = !existsSync(join(cwd, 'node_modules')) @@ -86,14 +105,12 @@ const runApp = async (dir: string, port: number) => { stderr: 'pipe', }) - _runningApps.set(dir, { - name: dir, - port, - proc, - started: Date.now() - }) + // Set state to running + app.state = 'running' + app.proc = proc + app.started = Date.now() - const streamOutput = async (stream: ReadableStream | null, isErr: boolean) => { + const streamOutput = async (stream: ReadableStream | null) => { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() @@ -102,14 +119,13 @@ const runApp = async (dir: string, port: number) => { if (done) break const text = decoder.decode(value).trimEnd() if (text) { - //isErr ? err(dir, text) : info(dir, text) log(dir, text) } } } - streamOutput(proc.stdout, false) - streamOutput(proc.stderr, true) + streamOutput(proc.stdout) + streamOutput(proc.stderr) // Handle process exit proc.exited.then(code => { @@ -117,43 +133,88 @@ const runApp = async (dir: string, port: number) => { err(dir, `Exited with code ${code}`) else 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[] => - Array.from(_runningApps.values()) - .sort((a, b) => a.port - b.port) +/** Returns all apps */ +export const allApps = (): App[] => + Array.from(_apps.values()) + .sort((a, b) => a.name.localeCompare(b.name)) -const stopApp = (dir: string) => { - const app = _runningApps.get(dir) - if (app) { - info(dir, 'Stopping...') - app.proc.kill() - } +/** 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 + + info(dir, 'Stopping...') + app.state = 'stopping' + app.proc?.kill() } const watchAppsDir = () => { - watch(APPS_DIR, { recursive: true }, (event, filename) => { + watch(APPS_DIR, { recursive: true }, (_event, filename) => { 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") const dir = filename.split('/')[0]! - if (isApp(dir) && !_runningApps.has(dir)) { - const port = getPort() - runApp(dir, port) + // Handle new directory appearing + if (!_apps.has(dir)) { + 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)) - stopApp(dir) + const app = _apps.get(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 = () => { + discoverApps() runApps() watchAppsDir() } \ No newline at end of file diff --git a/src/server/index.tsx b/src/server/index.tsx index ecabf38..4791c14 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,9 +1,37 @@ import { Hype } from 'hype' -import { initApps } from './apps' +import { initApps, startApp, stopApp } from './apps' const app = new Hype() console.log('🐾 Toes!') 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 diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..7319833 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,8 @@ +export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping' + +export type App = { + name: string + state: AppState + port?: number + started?: number +}