This commit is contained in:
Chris Wanstrath 2026-01-27 16:05:58 -08:00
parent fa459c45eb
commit aaf6ce8361
3 changed files with 161 additions and 161 deletions

8
src/pages/index.tsx Normal file
View File

@ -0,0 +1,8 @@
import { runningApps } from '../server/apps'
export default () => (
<>
<h1>🐾 Running Apps</h1>
{runningApps().map(app => <h2><a href={`http://localhost:${app.port}`}>{app.port}: {app.name}</a></h2>)}
</>
)

150
src/server/apps.ts Normal file
View File

@ -0,0 +1,150 @@
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
proc: Subprocess
}
const _runningApps = new Map<string, RunningApp>()
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)
.sort()
}
let NEXT_PORT = 3001
const getPort = () => NEXT_PORT++
export const runApps = () => {
for (const dir of appNames()) {
if (!isApp(dir)) continue
const port = getPort()
runApp(dir, port)
}
}
const isApp = (dir: string): boolean => {
try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
const json = JSON.parse(file)
return !!json.scripts?.toes
} catch (e) {
return false
}
}
const loadApp = (dir: string) => {
try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
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, '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 })
const streamOutput = async (stream: ReadableStream<Uint8Array> | 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 = () =>
Array.from(_runningApps.values())
.map(({ name, port }) => ({ name, port }))
.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, (event, filename) => {
console.log('apps dir')
if (!filename) return
if (isApp(filename) && !_runningApps.has(filename)) {
const port = getPort()
runApp(filename, port)
}
if (_runningApps.has(filename) && !isApp(filename))
stopApp(filename)
})
}
export const initApps = () => {
runApps()
watchAppsDir()
}

View File

@ -1,167 +1,9 @@
import type { Subprocess } from 'bun'
import { Hype } from 'hype'
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
proc: Subprocess
}
const runningApps = new Map<string, RunningApp>()
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)
const appNames = () => {
return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort()
}
let NEXT_PORT = 3001
const getPort = () => NEXT_PORT++
const runApps = () => {
for (const dir of appNames()) {
if (!isApp(dir)) continue
const port = getPort()
runApp(dir, port)
}
}
const isApp = (dir: string): boolean => {
try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
const json = JSON.parse(file)
return !!json.scripts?.toes
} catch (e) {
return false
}
}
const loadApp = (dir: string) => {
try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
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, '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 })
const streamOutput = async (stream: ReadableStream<Uint8Array> | 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)
})
}
const getRunningApps = () =>
Array.from(runningApps.values()).map(({ name, port }) => ({ name, port }))
const stopApp = (dir: string) => {
const app = runningApps.get(dir)
if (app) {
info(dir, 'Stopping...')
app.proc.kill()
}
}
const watchAppsDir = () => {
watch(APPS_DIR, (event, filename) => {
console.log('apps dir')
if (!filename) return
if (isApp(filename) && !runningApps.has(filename)) {
const port = getPort()
runApp(filename, port)
}
if (runningApps.has(filename) && !isApp(filename))
stopApp(filename)
})
}
const startup = () => {
console.log('🐾 Toes!')
runApps()
watchAppsDir()
}
import { initApps } from './apps'
const app = new Hype()
app.get('/', c => {
return c.html(
<>
<h1>🐾 Running Apps</h1>
{getRunningApps().map(app => <h2><a href={`http://localhost:${app.port}`}>{app.port}: {app.name}</a></h2>)}
</>
)
})
console.log('🐾 Toes!')
initApps()
startup()
export { getRunningApps, stopApp }
export default app.defaults