toes/src/server/api/apps.ts
Claude 2f4d609290
Fix app rename failing with "port is taken" error
renameApp() killed the old process with .kill() but didn't wait for it
to actually exit before restarting on the same port. The OS still had
the port bound, causing the new process to fail with "port is taken".

Additionally, the old process's exit handler would fire after the rename
and corrupt the app's state—releasing the new process's port, setting
state to 'invalid', and nullifying the proc reference.

Fix by:
- Making renameApp async and awaiting proc.exited before proceeding
- Guarding the exit handler to bail out when a newer process has taken over

https://claude.ai/code/session_01W9GF8Cy7T6V2rnVcoNd1Nc
2026-02-12 16:13:59 +00:00

387 lines
11 KiB
TypeScript

import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import { Hype } from '@because/hype'
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
const timestamp = () => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
}
const router = Hype.router()
// BackendApp -> SharedApp
function convert(app: BackendApp): SharedApp {
const { proc, logs, ...rest } = app
return { ...rest, pid: proc?.pid }
}
// SSE endpoint for real-time app state updates
router.sse('/stream', (send) => {
const broadcast = () => {
const apps: SharedApp[] = allApps().map(({
name, state, icon, error, port, started, logs, tool
}) => ({
name, state, icon, error, port, started, logs, tool,
}))
send(apps)
}
broadcast()
const unsub = onChange(broadcast)
return () => unsub()
})
router.get('/', c => c.json(allApps().map(convert)))
router.get('/:app', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
return c.json(convert(app))
})
router.get('/:app/logs', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
// Check for date query param to read from disk
const date = c.req.query('date')
const tailParam = c.req.query('tail')
const tail = tailParam ? parseInt(tailParam, 10) : undefined
if (date) {
// Read from disk for historical logs
const lines = readLogs(appName, date, tail)
return c.json(lines)
}
// Return in-memory logs for today (real-time)
const logs = app.logs ?? []
if (tail && tail > 0) {
return c.json(logs.slice(-tail))
}
return c.json(logs)
})
router.post('/:app/logs', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
let body: { text?: string, stream?: 'stdout' | 'stderr' }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const text = body.text?.trimEnd()
if (!text) return c.json({ ok: false, error: 'Text is required' }, 400)
appendLog(appName, text, body.stream ?? 'stdout')
return c.json({ ok: true })
})
router.get('/:app/logs/dates', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
return c.json(getLogDates(appName))
})
router.post('/', async c => {
let body: { name?: string, template?: TemplateType, tool?: boolean }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const name = body.name?.trim().toLowerCase().replace(/\s+/g, '-')
if (!name) return c.json({ ok: false, error: 'App name is required' }, 400)
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
return c.json({ ok: false, error: 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' }, 400)
}
const appPath = join(APPS_DIR, name)
if (existsSync(appPath)) {
return c.json({ ok: false, error: 'An app with this name already exists' }, 400)
}
const template = body.template ?? 'ssr'
const templates = generateTemplates(name, template, { tool: body.tool })
// Create versioned directory structure
const ts = timestamp()
const versionPath = join(appPath, ts)
const currentPath = join(appPath, 'current')
// Create directories and write files into version directory
for (const [filename, content] of Object.entries(templates)) {
const fullPath = join(versionPath, filename)
const dir = dirname(fullPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(fullPath, content)
}
// Create current symlink
symlinkSync(ts, currentPath)
// Register and start the app
registerApp(name)
return c.json({ ok: true, name })
})
router.sse('/:app/logs/stream', (send, c) => {
const appName = c.req.param('app')
const targetApp = allApps().find(a => a.name === appName)
if (!targetApp) return
let lastLogCount = 0
const sendNewLogs = () => {
const currentApp = allApps().find(a => a.name === appName)
if (!currentApp) return
const logs = currentApp.logs ?? []
const newLogs = logs.slice(lastLogCount)
lastLogCount = logs.length
for (const line of newLogs) {
send(line)
}
}
sendNewLogs()
const unsub = onChange(sendNewLogs)
return () => unsub()
})
router.post('/:app/start', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
startApp(appName)
return c.json({ ok: true })
})
router.post('/:app/restart', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
stopApp(appName)
startApp(appName)
return c.json({ ok: true })
})
router.post('/:app/stop', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
stopApp(appName)
return c.json({ ok: true })
})
router.post('/:app/icon', c => {
const appName = c.req.param('app')
const icon = c.req.query('icon') ?? ''
if (!icon) return c.json({ error: 'No icon query param provided' })
try {
updateAppIcon(appName, icon)
return c.json({ ok: true })
} catch (error) {
return c.json({ error })
}
})
router.post('/:app/rename', async c => {
const appName = c.req.param('app')
let body: { name?: string }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const newName = body.name?.trim().toLowerCase().replace(/\s+/g, '-')
if (!newName) return c.json({ ok: false, error: 'New name is required' }, 400)
const result = await renameApp(appName, newName)
if (!result.ok) return c.json(result, 400)
return c.json({ ok: true, name: newName })
})
// --- Environment Variables ---
interface EnvVar {
key: string
value: string
}
const appEnvPath = (appName: string) => join(envDir(), `${appName}.env`)
const envDir = () => join(TOES_DIR, 'env')
const globalEnvPath = () => join(envDir(), '_global.env')
function parseEnvFile(path: string): EnvVar[] {
if (!existsSync(path)) return []
const content = readFileSync(path, 'utf-8')
const vars: EnvVar[] = []
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (key) vars.push({ key, value })
}
return vars
}
function writeEnvFile(path: string, vars: EnvVar[]) {
const dir = dirname(path)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
writeFileSync(path, content)
}
// Global env vars
router.get('/env', c => {
return c.json(parseEnvFile(globalEnvPath()))
})
router.post('/env', async c => {
let body: { key?: string, value?: string }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const key = body.key?.trim().toUpperCase()
const value = body.value ?? ''
if (!key) return c.json({ ok: false, error: 'Key is required' }, 400)
const path = globalEnvPath()
const vars = parseEnvFile(path)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(path, vars)
return c.json({ ok: true })
})
router.delete('/env/:key', c => {
const key = c.req.param('key')
if (!key) return c.json({ error: 'Key required' }, 400)
const path = globalEnvPath()
const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase())
writeEnvFile(path, vars)
return c.json({ ok: true })
})
// App env vars
router.get('/:app/env', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
return c.json(parseEnvFile(appEnvPath(appName)))
})
// Set env var for an app
router.post('/:app/env', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
let body: { key?: string, value?: string }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const key = body.key?.trim().toUpperCase()
const value = body.value ?? ''
if (!key) return c.json({ ok: false, error: 'Key is required' }, 400)
const path = appEnvPath(appName)
const vars = parseEnvFile(path)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(path, vars)
// Restart app to pick up new env
await restartApp(appName)
return c.json({ ok: true })
})
// Delete env var for an app
router.delete('/:app/env/:key', async c => {
const appName = c.req.param('app')
const key = c.req.param('key')
if (!appName || !key) return c.json({ error: 'App and key required' }, 400)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
const path = appEnvPath(appName)
const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase())
writeEnvFile(path, vars)
// Restart app to pick up removed env
await restartApp(appName)
return c.json({ ok: true })
})
export default router