From 714ed145816991ccd76056aed4f97661f71a8cde Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 29 Jan 2026 18:15:55 -0800 Subject: [PATCH] split up some modules --- src/server/api/apps.ts | 162 +++++++++++++++++++++++++++++++ src/server/api/sync.ts | 68 +++++++++++++ src/server/index.tsx | 210 +---------------------------------------- 3 files changed, 235 insertions(+), 205 deletions(-) create mode 100644 src/server/api/apps.ts create mode 100644 src/server/api/sync.ts diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts new file mode 100644 index 0000000..1ca8880 --- /dev/null +++ b/src/server/api/apps.ts @@ -0,0 +1,162 @@ +import { allApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' +import type { App as BackendApp } from '$apps' +import type { App as SharedApp } from '@types' +import { Hono } from 'hono' + +const router = new Hono() + +// BackendApp -> SharedApp +function convert(app: BackendApp): SharedApp { + const clone = { ...app } + delete clone.proc + delete clone.logs + return clone +} + +// SSE endpoint for real-time app state updates +router.get('/stream', c => { + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + start(controller) { + const send = () => { + // Strip proc field from apps before sending + const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({ + name, + state, + icon, + error, + port, + started, + logs, + })) + const data = JSON.stringify(apps) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + + // Send initial state + send() + + // Subscribe to changes + const unsub = onChange(send) + + // Handle client disconnect via abort signal + c.req.raw.signal.addEventListener('abort', () => { + unsub() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +}) + +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) + + return c.json(app.logs ?? []) +}) + +router.get('/:app/logs/stream', c => { + const appName = c.req.param('app') + const targetApp = allApps().find(a => a.name === appName) + if (!targetApp) { + return c.json({ error: 'App not found' }, 404) + } + + const encoder = new TextEncoder() + let lastLogCount = 0 + + const stream = new ReadableStream({ + start(controller) { + 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) { + controller.enqueue(encoder.encode(`data: ${line}\n\n`)) + } + } + + sendNewLogs() + const unsub = onChange(sendNewLogs) + + c.req.raw.signal.addEventListener('abort', () => { + unsub() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +}) + +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 }) + } +}) + +export default router diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts new file mode 100644 index 0000000..cfe6bdf --- /dev/null +++ b/src/server/api/sync.ts @@ -0,0 +1,68 @@ +import { APPS_DIR, allApps } from '$apps' +import { generateManifest } from '../sync' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { Hono } from 'hono' + +const router = new Hono() + +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) + 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 fullPath = join(APPS_DIR, appName, filePath) + if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) + + const content = readFileSync(fullPath) + return new Response(content) +}) + +router.put('/apps/:app/files/:path{.+}', async c => { + const appName = c.req.param('app') + const filePath = c.req.param('path') + + if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) + + const fullPath = join(APPS_DIR, appName, filePath) + 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/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 fullPath = join(APPS_DIR, appName, filePath) + if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) + + unlinkSync(fullPath) + return c.json({ ok: true }) +}) + +export default router diff --git a/src/server/index.tsx b/src/server/index.tsx index a357ff0..b18317d 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,212 +1,12 @@ -import { APPS_DIR, allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' -import type { App as SharedApp } from '@types' -import type { App as BackendApp } from '$apps' -import { generateManifest } from './sync' -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' -import { dirname, join } from 'path' +import { initApps } from '$apps' +import appsRouter from './api/apps' +import syncRouter from './api/sync' import { Hype } from 'hype' -// BackendApp -> SharedApp -function convert(app: BackendApp): SharedApp { - const clone = { ...app } - delete clone.proc - delete clone.logs - return clone -} - const app = new Hype({ layout: false }) -// SSE endpoint for real-time app state updates -app.get('/api/apps/stream', c => { - const encoder = new TextEncoder() - - const stream = new ReadableStream({ - start(controller) { - const send = () => { - // Strip proc field from apps before sending - const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({ - name, - state, - icon, - error, - port, - started, - logs, - })) - const data = JSON.stringify(apps) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } - - // Send initial state - send() - - // Subscribe to changes - const unsub = onChange(send) - - // Handle client disconnect via abort signal - c.req.raw.signal.addEventListener('abort', () => { - unsub() - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) -}) - -app.get('/api/apps', c => - c.json(allApps().map(convert)) -) - -app.get('/api/apps/: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)) -}) - -app.get('/api/apps/: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) - - return c.json(app.logs ?? []) -}) - -app.sse('/api/apps/: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() -}) - -app.post('/api/apps/: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 }) -}) - -app.post('/api/apps/: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 }) -}) - -app.post('/api/apps/: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 }) -}) - -app.post('/api/apps/: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 }) - } -}) - -// Sync API -app.get('/api/sync/apps', c => - c.json(allApps().map(a => a.name)) -) - -app.get('/api/sync/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) - if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) - - const manifest = generateManifest(appPath, appName) - return c.json(manifest) -}) - -app.get('/api/sync/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 fullPath = join(APPS_DIR, appName, filePath) - if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) - - const content = readFileSync(fullPath) - return new Response(content) -}) - -app.put('/api/sync/apps/:app/files/:path{.+}', async c => { - const appName = c.req.param('app') - const filePath = c.req.param('path') - - if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - - const fullPath = join(APPS_DIR, appName, filePath) - 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 }) -}) - -app.delete('/api/sync/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 fullPath = join(APPS_DIR, appName, filePath) - if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) - - unlinkSync(fullPath) - return c.json({ ok: true }) -}) +app.route('/api/apps', appsRouter) +app.route('/api/sync', syncRouter) console.log('🐾 Toes!') initApps()