278 lines
6.8 KiB
TypeScript
278 lines
6.8 KiB
TypeScript
import type { App as SharedApp, AppState, LogLine } from '@types'
|
|
import type { Subprocess } from 'bun'
|
|
import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
export type { AppState } from '@types'
|
|
|
|
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
|
|
|
const DEFAULT_EMOJI = '🖥️'
|
|
const MAX_LOGS = 100
|
|
const _apps = new Map<string, App>()
|
|
const _listeners = new Set<() => void>()
|
|
|
|
let NEXT_PORT = 3001
|
|
|
|
export type App = SharedApp & {
|
|
proc?: Subprocess
|
|
}
|
|
|
|
type LoadResult = { pkg: any; error?: string }
|
|
|
|
export const allApps = (): App[] =>
|
|
Array.from(_apps.values())
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
export const getApp = (dir: string): App | undefined =>
|
|
_apps.get(dir)
|
|
|
|
export const runApps = () =>
|
|
allAppDirs().filter(isApp).forEach(startApp)
|
|
|
|
export const runningApps = (): App[] =>
|
|
allApps().filter(a => a.state === 'running')
|
|
|
|
export function initApps() {
|
|
discoverApps()
|
|
runApps()
|
|
watchAppsDir()
|
|
}
|
|
|
|
export function onChange(cb: () => void) {
|
|
_listeners.add(cb)
|
|
return () => _listeners.delete(cb)
|
|
}
|
|
|
|
export function startApp(dir: string) {
|
|
const app = _apps.get(dir)
|
|
if (!app || app.state !== 'stopped') return
|
|
if (!isApp(dir)) return
|
|
runApp(dir, getPort())
|
|
}
|
|
|
|
export function stopApp(dir: string) {
|
|
const app = _apps.get(dir)
|
|
if (!app || app.state !== 'running') return
|
|
|
|
info(dir, 'Stopping...')
|
|
app.state = 'stopping'
|
|
update()
|
|
app.proc?.kill()
|
|
}
|
|
|
|
export function updateAppIcon(dir: string, icon: string) {
|
|
const { pkg, error } = loadApp(dir)
|
|
if (error) throw new Error(error)
|
|
|
|
pkg.toes ??= {}
|
|
pkg.toes.icon = icon
|
|
saveApp(dir, pkg)
|
|
}
|
|
|
|
const err = (app: string, ...msg: string[]) =>
|
|
console.error('🐾', `${app}:`, ...msg)
|
|
|
|
const getPort = () => NEXT_PORT++
|
|
|
|
const info = (app: string, ...msg: string[]) =>
|
|
console.log('🐾', `${app}:`, ...msg)
|
|
|
|
const isApp = (dir: string): boolean =>
|
|
!loadApp(dir).error
|
|
|
|
const log = (app: string, ...msg: string[]) =>
|
|
console.log(`<${app}>`, ...msg)
|
|
|
|
const update = () => _listeners.forEach(cb => cb())
|
|
|
|
function allAppDirs() {
|
|
return readdirSync(APPS_DIR, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort()
|
|
}
|
|
|
|
function discoverApps() {
|
|
for (const dir of allAppDirs()) {
|
|
const { pkg, error } = loadApp(dir)
|
|
const state: AppState = error ? 'invalid' : 'stopped'
|
|
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
|
_apps.set(dir, { name: dir, state, icon, error })
|
|
}
|
|
}
|
|
|
|
function isDir(path: string): boolean {
|
|
try {
|
|
return statSync(path).isDirectory()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function loadApp(dir: string): LoadResult {
|
|
try {
|
|
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
|
|
|
|
try {
|
|
const json = JSON.parse(file)
|
|
|
|
if (json.scripts?.toes) {
|
|
return { pkg: json }
|
|
} else {
|
|
const error = 'Missing scripts.toes in package.json'
|
|
err(dir, error)
|
|
return { pkg: json, error }
|
|
}
|
|
} catch (e) {
|
|
const error = `Invalid JSON in package.json: ${e instanceof Error ? e.message : String(e)}`
|
|
err(dir, error)
|
|
return { pkg: {}, error }
|
|
}
|
|
} catch (e) {
|
|
const error = 'Missing package.json'
|
|
err(dir, error)
|
|
return { pkg: {}, error }
|
|
}
|
|
}
|
|
|
|
async function runApp(dir: string, port: number) {
|
|
const { pkg, error } = loadApp(dir)
|
|
if (error) return
|
|
|
|
const app = _apps.get(dir)
|
|
if (!app) return
|
|
|
|
// Set state to starting
|
|
app.state = 'starting'
|
|
app.port = port
|
|
app.logs = []
|
|
update()
|
|
|
|
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), NO_AUTOPORT: 'true' },
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
|
|
// Set state to running
|
|
app.state = 'running'
|
|
app.proc = proc
|
|
app.started = Date.now()
|
|
update()
|
|
|
|
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 chunk = decoder.decode(value)
|
|
const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean)
|
|
for (const text of lines) {
|
|
log(dir, text)
|
|
const line: LogLine = { time: Date.now(), text }
|
|
app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line]
|
|
}
|
|
if (lines.length) update()
|
|
}
|
|
}
|
|
|
|
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.port = undefined
|
|
app.started = undefined
|
|
update()
|
|
})
|
|
}
|
|
|
|
function saveApp(dir: string, pkg: any) {
|
|
const path = join(APPS_DIR, dir, 'package.json')
|
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
|
}
|
|
|
|
function 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 { pkg, error } = loadApp(dir)
|
|
const state: AppState = error ? 'invalid' : 'stopped'
|
|
const icon = pkg.toes?.icon
|
|
_apps.set(dir, { name: dir, state, icon, error })
|
|
update()
|
|
if (!error) {
|
|
runApp(dir, getPort())
|
|
}
|
|
return
|
|
}
|
|
|
|
const app = _apps.get(dir)!
|
|
|
|
// check if app was deleted
|
|
if (!isDir(join(APPS_DIR, dir))) {
|
|
_apps.delete(dir)
|
|
update()
|
|
return
|
|
}
|
|
|
|
// Only care about package.json changes for existing apps
|
|
if (!filename.endsWith('package.json')) return
|
|
|
|
const { pkg, error } = loadApp(dir)
|
|
|
|
// Update icon and error from package.json
|
|
app.icon = pkg.toes?.icon
|
|
app.error = error
|
|
|
|
// App became valid - start it if stopped
|
|
if (!error && app.state === 'invalid') {
|
|
app.state = 'stopped'
|
|
runApp(dir, getPort())
|
|
}
|
|
|
|
// App became invalid - stop it if running
|
|
if (error && app.state === 'running') {
|
|
app.state = 'invalid'
|
|
app.proc?.kill()
|
|
}
|
|
|
|
// Update state if already stopped/invalid
|
|
if (error && app.state === 'stopped') {
|
|
app.state = 'invalid'
|
|
update()
|
|
}
|
|
|
|
if (!error && app.state === 'invalid') {
|
|
app.state = 'stopped'
|
|
update()
|
|
}
|
|
})
|
|
}
|