toes/src/server/api/sync.ts
Chris Wanstrath ebf3ffc3af spicy
2026-01-30 16:16:59 -08:00

266 lines
8.3 KiB
TypeScript

import { APPS_DIR, allApps, removeApp, restartApp, startApp, stopApp } from '$apps'
import { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore'
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { Hype } from '@because/hype'
interface FileChangeEvent {
hash?: string
path: string
type: 'change' | 'delete'
}
function safePath(base: string, ...parts: string[]): string | null {
// Resolve base to canonical path (follows symlinks) if it exists
const canonicalBase = existsSync(base) ? realpathSync(base) : base
const resolved = join(canonicalBase, ...parts)
if (!resolved.startsWith(canonicalBase + '/') && resolved !== canonicalBase) {
return null
}
return resolved
}
const router = Hype.router()
router.get('/apps', c => c.json(allApps().map(a => a.name)))
router.get('/apps/:app/manifest', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appPath = join(APPS_DIR, appName, 'current')
const safeAppPath = safePath(APPS_DIR, appName)
if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404)
const manifest = generateManifest(appPath, appName)
return c.json(manifest)
})
router.get('/apps/:app/files/:path{.+}', c => {
const appName = c.req.param('app')
const filePath = c.req.param('path')
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
const basePath = join(APPS_DIR, appName, 'current')
const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404)
const content = readFileSync(fullPath)
return new Response(content, {
headers: { 'Content-Type': 'application/octet-stream' },
})
})
router.put('/apps/:app/files/:path{.+}', async c => {
const appName = c.req.param('app')
const filePath = c.req.param('path')
const version = c.req.query('version') // Optional version parameter
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
// Determine base path: specific version or current
const basePath = version
? join(APPS_DIR, appName, version)
: join(APPS_DIR, appName, 'current')
const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
const dir = dirname(fullPath)
// Ensure directory exists
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const body = await c.req.arrayBuffer()
writeFileSync(fullPath, new Uint8Array(body))
return c.json({ ok: true })
})
router.delete('/apps/:app', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appPath = safePath(APPS_DIR, appName)
if (!appPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404)
removeApp(appName)
rmSync(appPath, { recursive: true })
return c.json({ ok: true })
})
router.delete('/apps/:app/files/:path{.+}', c => {
const appName = c.req.param('app')
const filePath = c.req.param('path')
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
const basePath = join(APPS_DIR, appName, 'current')
const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404)
unlinkSync(fullPath)
return c.json({ ok: true })
})
router.post('/apps/:app/deploy', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App name required' }, 400)
const appDir = join(APPS_DIR, appName)
// Generate timestamp: YYYYMMDD-HHMMSS format
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const newVersion = join(appDir, timestamp)
const currentLink = join(appDir, 'current')
try {
// 1. If current exists, copy it to new timestamp
const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null
if (currentReal) {
cpSync(currentReal, newVersion, { recursive: true })
} else {
// First deployment - create directory
mkdirSync(newVersion, { recursive: true })
}
return c.json({ ok: true, version: timestamp })
} catch (e) {
return c.json({ error: `Failed to create deployment: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
})
router.post('/apps/:app/activate', async c => {
const appName = c.req.param('app')
const version = c.req.query('version')
if (!appName) return c.json({ error: 'App name required' }, 400)
if (!version) return c.json({ error: 'Version required' }, 400)
const appDir = join(APPS_DIR, appName)
const versionDir = join(appDir, version)
const currentLink = join(appDir, 'current')
if (!existsSync(versionDir)) {
return c.json({ error: 'Version not found' }, 404)
}
try {
// Atomic symlink update
const tempLink = join(appDir, '.current.tmp')
// Remove temp link if it exists from previous failed attempt
if (existsSync(tempLink)) {
unlinkSync(tempLink)
}
// Create new symlink pointing to version directory (relative)
symlinkSync(version, tempLink, 'dir')
// Atomic rename over old symlink/directory
renameSync(tempLink, currentLink)
// Clean up old versions (keep 5 most recent)
try {
const entries = readdirSync(appDir, { withFileTypes: true })
// Get all timestamp directories (exclude current, .current.tmp, etc)
const versionDirs = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
// Keep 5 most recent, delete the rest
const toDelete = versionDirs.slice(5)
for (const dir of toDelete) {
const dirPath = join(appDir, dir)
rmSync(dirPath, { recursive: true, force: true })
console.log(`Cleaned up old version: ${dir}`)
}
} catch (e) {
// Log but don't fail activation if cleanup fails
console.error(`Failed to clean up old versions: ${e}`)
}
// Restart app to use new version
const app = allApps().find(a => a.name === appName)
if (app?.state === 'running') {
try {
await restartApp(appName)
} catch (e) {
return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
}
return c.json({ ok: true })
} catch (e) {
return c.json({ error: `Failed to activate version: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
})
router.sse('/apps/:app/watch', (send, c) => {
const appName = c.req.param('app')
const appPath = join(APPS_DIR, appName, 'current')
const safeAppPath = safePath(APPS_DIR, appName)
if (!safeAppPath || !existsSync(appPath)) return
// Resolve to canonical path for consistent watch events
const canonicalPath = realpathSync(appPath)
const gitignore = loadGitignore(canonicalPath)
let debounceTimer: Timer | null = null
const pendingChanges = new Map<string, 'change' | 'delete'>()
const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => {
if (!filename || gitignore.shouldExclude(filename)) return
const fullPath = join(canonicalPath, filename)
const type = existsSync(fullPath) ? 'change' : 'delete'
pendingChanges.set(filename, type)
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
for (const [path, changeType] of pendingChanges) {
const evt: FileChangeEvent = { type: changeType, path }
if (changeType === 'change') {
try {
const content = readFileSync(join(canonicalPath, path))
evt.hash = computeHash(content)
} catch {
continue // File was deleted between check and read
}
}
send(evt)
}
pendingChanges.clear()
}, 100)
})
return () => {
if (debounceTimer) clearTimeout(debounceTimer)
watcher.close()
}
})
export default router