import type { Subprocess } from 'bun' import { existsSync, readdirSync, readFileSync, watch } from 'fs' import { join } from 'path' const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') type RunningApp = { name: string port: number started: number proc: Subprocess } const _runningApps = new Map() const err = (app: string, ...msg: string[]) => console.error('🐾', `${app}:`, ...msg) const info = (app: string, ...msg: string[]) => console.log('🐾', `${app}:`, ...msg) const log = (app: string, ...msg: string[]) => console.log(`<${app}>`, ...msg) export const appNames = () => { return readdirSync(APPS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .filter(isApp) .sort() } let NEXT_PORT = 3001 const getPort = () => NEXT_PORT++ export const runApps = () => { for (const dir of appNames()) { const port = getPort() runApp(dir, port) } } const isApp = (dir: string): boolean => Object.values(loadApp(dir)).length > 0 const loadApp = (dir: string) => { try { const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') try { const json = JSON.parse(file) if (json.scripts?.toes) { return json } else { err(dir, 'No `bun toes` script in package.json') return {} } } catch (e) { err(dir, 'Invalid JSON in package.json:', e instanceof Error ? e.message : String(e)) return {} } } catch (e) { err(dir, 'No package.json') return {} } } const runApp = async (dir: string, port: number) => { const pkg = loadApp(dir) if (!pkg.scripts?.toes) return const cwd = join(APPS_DIR, dir) const needsInstall = !existsSync(join(cwd, 'node_modules')) if (needsInstall) info(dir, 'Installing dependencies...') const install = Bun.spawn(['bun', 'install'], { cwd, stdout: 'pipe', stderr: 'pipe' }) await install.exited info(dir, `Starting on port ${port}...`) const proc = Bun.spawn(['bun', 'run', 'toes'], { cwd, env: { ...process.env, PORT: String(port) }, stdout: 'pipe', stderr: 'pipe', }) _runningApps.set(dir, { name: dir, port, proc, started: Date.now() }) const streamOutput = async (stream: ReadableStream | null, isErr: boolean) => { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() 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) // Handle process exit proc.exited.then(code => { if (code !== 0) err(dir, `Exited with code ${code}`) else info(dir, 'Stopped') _runningApps.delete(dir) }) } export const runningApps = (): RunningApp[] => Array.from(_runningApps.values()) .sort((a, b) => a.port - b.port) const stopApp = (dir: string) => { const app = _runningApps.get(dir) if (app) { info(dir, 'Stopping...') app.proc.kill() } } const watchAppsDir = () => { 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) } if (_runningApps.has(dir) && !isApp(dir)) stopApp(dir) }) } export const initApps = () => { runApps() watchAppsDir() }