159 lines
3.7 KiB
TypeScript
159 lines
3.7 KiB
TypeScript
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<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)
|
|
.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<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 = (): 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()
|
|
} |