toes/src/server/apps.ts

220 lines
5.5 KiB
TypeScript

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')
export type App = SharedApp & {
proc?: Subprocess
}
const _apps = new Map<string, App>()
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)
/** Returns all directory names in APPS_DIR */
const allAppDirs = () => {
return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.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()
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 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'))
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',
})
// Set state to running
app.state = 'running'
app.proc = proc
app.started = Date.now()
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
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) {
log(dir, text)
}
}
}
streamOutput(proc.stdout)
streamOutput(proc.stderr)
// Handle process exit
proc.exited.then(code => {
if (code !== 0)
err(dir, `Exited with code ${code}`)
else
info(dir, 'Stopped')
// Reset to stopped state (or invalid if no longer valid)
app.state = isApp(dir) ? 'stopped' : 'invalid'
app.proc = undefined
app.started = undefined
})
}
/** Returns all apps */
export const allApps = (): App[] =>
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
info(dir, 'Stopping...')
app.state = 'stopping'
app.proc?.kill()
}
const watchAppsDir = () => {
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
if (!filename) return
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp")
const dir = filename.split('/')[0]!
// 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
}
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()
}