From ca45301e3e1c8d3b16195f70335c70179ff75f83 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 22 Feb 2026 03:35:26 +0000 Subject: [PATCH] App Preview --- example.csv | 13 + src/server.tsx | 87 +++++- src/servers/api/dev-server/router.ts | 279 ++++++++++++++++++ src/servers/hono.ts | 3 + .../src/AppRegistry/AppRegistry.tsx | 3 +- .../src/apps/Preview/PreviewApp.tsx | 69 +++++ .../src/apps/Preview/PreviewContext.tsx | 27 ++ .../src/apps/Preview/PreviewHeader.tsx | 37 +++ .../src/apps/Preview/PreviewProvider.tsx | 106 +++++++ .../officerdev/src/apps/Preview/index.ts | 16 + 10 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 example.csv create mode 100644 src/servers/api/dev-server/router.ts create mode 100644 src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx create mode 100644 src/workspaces/officerdev/src/apps/Preview/PreviewContext.tsx create mode 100644 src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Preview/PreviewProvider.tsx create mode 100644 src/workspaces/officerdev/src/apps/Preview/index.ts diff --git a/example.csv b/example.csv new file mode 100644 index 00000000..e1748c10 --- /dev/null +++ b/example.csv @@ -0,0 +1,13 @@ +Month,Revenue,Expenses,Profit +January,12000,8500,3500 +February,15000,9200,5800 +March,11000,7800,3200 +April,18000,10500,7500 +May,22000,12000,10000 +June,19500,11200,8300 +July,25000,13500,11500 +August,23000,12800,10200 +September,20000,11000,9000 +October,27000,14200,12800 +November,30000,15500,14500 +December,35000,18000,17000 diff --git a/src/server.tsx b/src/server.tsx index 336637d0..f7036ce3 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -7,6 +7,7 @@ import { verify } from './servers/jwt'; import { officerdb, TokenBlacklist } from 'officerdb'; import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket'; import { piWebsocket } from './servers/api/pi/websocket'; +import { findEntryBySlug, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.html'; const { PORT = '5000' } = process.env; @@ -15,13 +16,16 @@ type WSData = { userId: number; email: string; role: string; - provider: 'terminal' | 'pi'; + provider: 'terminal' | 'pi' | 'dev-server'; sandboxed: boolean; sessionId?: string; cwd?: string; command?: string; cols?: number; rows?: number; + devServerPort?: number; + devServerSlug?: string; + wsProxyPath?: string; }; const handlers: Record = { @@ -29,6 +33,56 @@ const handlers: Record = { pi: piWebsocket, }; +// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.) +type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean }; +const devServerUpstreams = new Map, UpstreamState>(); + +const devServerWebsocket = { + open(ws: ServerWebSocket) { + const { devServerPort, wsProxyPath } = ws.data; + const upstream = new WebSocket(`ws://localhost:${devServerPort}${wsProxyPath}`); + const state: UpstreamState = { ws: upstream, queue: [], ready: false }; + devServerUpstreams.set(ws, state); + + upstream.addEventListener('open', () => { + state.ready = true; + for (const msg of state.queue) upstream.send(msg); + state.queue.length = 0; + }); + + upstream.addEventListener('message', (event) => { + ws.send(event.data as string | ArrayBuffer); + }); + + upstream.addEventListener('close', () => { + devServerUpstreams.delete(ws); + ws.close(); + }); + + upstream.addEventListener('error', () => { + devServerUpstreams.delete(ws); + ws.close(); + }); + }, + message(ws: ServerWebSocket, raw: string | Buffer) { + const state = devServerUpstreams.get(ws); + if (!state) return; + if (state.ready) { + state.ws.send(raw); + } else { + state.queue.push(raw); + } + }, + close(ws: ServerWebSocket) { + const state = devServerUpstreams.get(ws); + if (state) { + state.ws.close(); + devServerUpstreams.delete(ws); + } + }, +}; +handlers['dev-server'] = devServerWebsocket; + async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi') { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); @@ -60,6 +114,33 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi') } } +function upgradeDevServerWs(req: Request, server: any) { + const url = new URL(req.url); + const match = url.pathname.match(/^\/api\/dev-server-proxy\/([^/]+)(\/.*)?$/); + if (!match) return new Response('Not found', { status: 404 }); + + const slug = match[1]!; + const entry = findEntryBySlug(slug); + if (!entry) return new Response('No dev server running', { status: 404 }); + + touchEntry(entry); + + const wsProxyPath = match[2] || '/'; + const ok = server.upgrade(req, { + data: { + userId: 0, + email: '', + role: '', + provider: 'dev-server' as const, + sandboxed: false, + devServerPort: entry.port, + devServerSlug: slug, + wsProxyPath, + }, + }); + if (!ok) return new Response('Upgrade failed', { status: 500 }); +} + const server = serve({ port: Number(PORT), idleTimeout: 60, @@ -71,6 +152,10 @@ const server = serve({ if (await file.exists()) return new Response(file); return new Response(null, { status: 404 }); }, + '/api/dev-server-proxy/*': (req, server) => { + if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server); + return honoServer.fetch(req, server); + }, '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), '/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'), '/': officerWeb, diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts new file mode 100644 index 00000000..f8edb54a --- /dev/null +++ b/src/servers/api/dev-server/router.ts @@ -0,0 +1,279 @@ +import type { Subprocess } from 'bun'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { createRouter } from '@@/create-router'; +import { getUserProjectsDir } from '@@/data-path'; +import { CustomError } from '@@/custom-errors'; +import * as errors from '@@/custom-errors'; + +export const devServerRouter = createRouter(); +export const devServerProxyRouter = createRouter(); + +export type ServerEntry = { proc: Subprocess; port: number; slug: string; logs: string[]; idleTimer: Timer | null }; + +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +const servers = new Map(); + +const serverKey = (email: string, slug: string) => `${email}:${slug}`; + +export const findEntryBySlug = (slug: string): ServerEntry | undefined => { + for (const entry of servers.values()) { + if (entry.slug === slug) return entry; + } + return undefined; +}; + +export const touchEntry = (entry: ServerEntry) => { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = setTimeout(() => { + console.log(`[dev-server] ${entry.slug} idle for 5m, stopping`); + entry.proc.kill(); + }, IDLE_TIMEOUT_MS); +}; + +const killEntry = (entry: ServerEntry, key: string) => { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.proc.kill(); + servers.delete(key); +}; + +const findFreePort = (): Promise => + new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); + +const collectLogs = (entry: ServerEntry, stream: ReadableStream | null, prefix: string) => { + if (!stream) return; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const pump = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + for (const line of text.split('\n').filter(Boolean)) { + entry.logs.push(`[${prefix}] ${line}`); + if (entry.logs.length > 200) entry.logs.shift(); + } + } + }; + pump().catch(() => {}); +}; + +const waitForPort = async (port: number, timeoutMs = 8000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(500) }); + await res.arrayBuffer(); + return true; + } catch { + await Bun.sleep(300); + } + } + return false; +}; + +devServerRouter.post('/start', async (ctx) => { + const user = ctx.get('user'); + const { slug } = ctx.get('body') as { slug: string }; + + if (!slug) throw errors.BAD_REQUEST('Missing slug'); + + const key = serverKey(user.email, slug); + + const existing = servers.get(key); + if (existing) { + touchEntry(existing); + return ctx.json({ url: `/api/dev-server-proxy/${slug}/`, port: existing.port }); + } + + const projectDir = join(getUserProjectsDir(user.email), slug); + if (!existsSync(projectDir)) throw errors.BAD_REQUEST(`Project directory not found: ${slug}`); + + const pkgPath = join(projectDir, 'package.json'); + if (!existsSync(pkgPath)) throw errors.BAD_REQUEST('No package.json found in project'); + try { + const pkg = JSON.parse(await Bun.file(pkgPath).text()); + if (!pkg.scripts?.dev) throw errors.BAD_REQUEST('No "dev" script in package.json'); + } catch (err) { + if (err instanceof CustomError) throw err; + throw errors.BAD_REQUEST('Failed to read package.json'); + } + + const port = await findFreePort(); + + const proc = Bun.spawn(['bun', 'dev'], { + cwd: projectDir, + env: { ...process.env, PORT: String(port) }, + stdout: 'pipe', + stderr: 'pipe', + }); + console.log(`[dev-server] started ${slug} on port ${port} (pid ${proc.pid})`); + + const entry: ServerEntry = { proc, port, slug, logs: [], idleTimer: null }; + servers.set(key, entry); + touchEntry(entry); + + collectLogs(entry, proc.stdout, 'stdout'); + collectLogs(entry, proc.stderr, 'stderr'); + + proc.exited.then((code) => { + console.log(`[dev-server] ${slug} exited with code ${code}`); + entry.logs.push(`[system] Process exited with code ${code}`); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + servers.delete(key); + }); + + const ready = await waitForPort(port); + if (!ready) { + const exitCode = proc.exitCode; + if (exitCode !== null) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + servers.delete(key); + throw errors.BAD_REQUEST(`Dev server exited with code ${exitCode}. Logs:\n${entry.logs.slice(-20).join('\n')}`); + } + } + + return ctx.json({ url: `/api/dev-server-proxy/${slug}/`, port }); +}); + +devServerRouter.post('/stop', async (ctx) => { + const user = ctx.get('user'); + const { slug } = ctx.get('body') as { slug: string }; + + if (!slug) throw errors.BAD_REQUEST('Missing slug'); + + const key = serverKey(user.email, slug); + const entry = servers.get(key); + + if (entry) killEntry(entry, key); + + return ctx.json({ ok: true }); +}); + +devServerRouter.get('/status', async (ctx) => { + const user = ctx.get('user'); + const slug = ctx.req.query('slug'); + + if (!slug) throw errors.BAD_REQUEST('Missing slug'); + + const key = serverKey(user.email, slug); + const entry = servers.get(key); + + if (entry) { + return ctx.json({ running: true, url: `/api/dev-server-proxy/${slug}/`, port: entry.port }); + } + + return ctx.json({ running: false }); +}); + +devServerRouter.get('/logs', async (ctx) => { + const user = ctx.get('user'); + const slug = ctx.req.query('slug'); + + if (!slug) throw errors.BAD_REQUEST('Missing slug'); + + const key = serverKey(user.email, slug); + const entry = servers.get(key); + + if (!entry) return ctx.json({ logs: [] }); + + return ctx.json({ logs: entry.logs.slice(-100) }); +}); + +// Proxy router — mounted outside protected router (no auth needed for sub-resources). +// Security: only proxies to ports that were started via authenticated /start calls. +devServerProxyRouter.all('/:slug/*', async (ctx) => { + const slug = ctx.req.param('slug'); + if (!slug) return ctx.text('Not found', 404); + + const entry = findEntryBySlug(slug); + if (!entry) return ctx.text('No dev server running', 404); + + touchEntry(entry); + + const originalUrl = new URL(ctx.req.url); + const prefix = `/api/dev-server-proxy/${slug}`; + const proxyPath = originalUrl.pathname.replace(prefix, '') || '/'; + const targetUrl = `http://localhost:${entry.port}${proxyPath}${originalUrl.search}`; + + try { + const headers = new Headers(ctx.req.raw.headers); + headers.delete('host'); + headers.delete('authorization'); + + const res = await fetch(targetUrl, { + method: ctx.req.method, + headers, + body: ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD' ? ctx.req.raw.body : undefined, + redirect: 'manual', + }); + + const contentType = res.headers.get('content-type') ?? ''; + const needsRewrite = + contentType.includes('text/html') || + contentType.includes('javascript') || + contentType.includes('text/css'); + + if (needsRewrite) { + const text = await res.text(); + const rewritten = contentType.includes('text/html') + ? rewriteHtml(text, prefix) + : rewriteAssetPaths(text, prefix); + const newHeaders = new Headers(res.headers); + newHeaders.delete('content-length'); + newHeaders.delete('content-encoding'); + return new Response(rewritten, { status: res.status, headers: newHeaders }); + } + + return new Response(res.body, { + status: res.status, + headers: res.headers, + }); + } catch { + return ctx.text('Dev server not reachable', 502); + } +}); + +// Injected into proxied HTML to intercept fetch/XHR so absolute paths (e.g. /api/hello) +// go through the proxy instead of hitting the host server directly. +// Handles string paths, URL objects, and Request objects. +const proxyOverrideScript = (base: string) => + ``; + +// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override +const rewriteHtml = (html: string, base: string) => + html + .replace(/]*)>/i, `${proxyOverrideScript(base)}`) + .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) + .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) + .replace(/url\(\//g, `url(${base}/`); + +// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific) +const rewriteAssetPaths = (text: string, base: string) => + text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index b799c4f0..b59aadf4 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -18,6 +18,7 @@ import { workspacesRouter } from './api/workspaces'; import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { piRestRouter } from './api/pi/rest'; +import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser } from './_middlewares'; @@ -39,6 +40,7 @@ honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); honoServer.route('/api/auth', authRouter); honoServer.route('/api/server-settings', serverSettingsRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter); +honoServer.route('/api/dev-server-proxy', devServerProxyRouter); const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); @@ -56,6 +58,7 @@ protectedRouter.route('/user', settingsRouter); protectedRouter.route('/workspaces', workspacesRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); +protectedRouter.route('/dev-server', devServerRouter); protectedRouter.route('/', piRestRouter); honoServer.route('/api', protectedRouter); diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 27a346f8..8e6fd3ce 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -6,9 +6,10 @@ import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces'; import { appRegistryMetas as projectMetas } from '../apps/Projects'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; +import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { useAppRegistry } from './useAppRegistry'; -const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas]; +const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas]; export const AppRegistry = () => { useAppRegistry(apps); diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx new file mode 100644 index 00000000..0e0a3d6b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -0,0 +1,69 @@ +import { Loader2, FolderKanban } from 'lucide-react'; +import { usePreview } from './PreviewContext'; + +export const PreviewApp = () => { + const { slug, cwdSlug, url, loading, error, iframeKey, projects, startServer, setSelectedSlug, clearError } = + usePreview(); + + if (!slug) { + if (projects.length === 0) { + return ( +
+ No projects found +
+ ); + } + + return ( +
+ + Select a project to preview +
+ {projects.map((p) => ( + + ))} +
+
+ ); + } + + if (loading) { + return ( +
+ + Starting dev server... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ + {!cwdSlug && ( + + )} +
+
+ ); + } + + if (!url) return null; + + return