| 1 | import {createReadStream, existsSync} from 'node:fs'; |
| 2 | import {readFile} from 'node:fs/promises'; |
| 3 | import {createServer} from 'node:http'; |
| 4 | import path from 'node:path'; |
| 5 | import {spawn} from 'node:child_process'; |
| 6 | import {fileURLToPath} from 'node:url'; |
| 7 | import {readAgentConfig, saveAgentConfig} from './config-store.mjs'; |
| 8 | import { |
| 9 | artifactContentType, |
| 10 | deleteSession, |
| 11 | listSessionArtifacts, |
| 12 | readSessionHistory, |
| 13 | readSessionState, |
| 14 | resolveArtifactPath, |
| 15 | storeWorkspaceUpload, |
| 16 | } from './server-lib.mjs'; |
| 17 | |
| 18 | const webRoot = path.dirname(fileURLToPath(import.meta.url)); |
| 19 | const repoRoot = path.resolve(webRoot, '..'); |
| 20 | const isDev = process.argv.includes('--dev'); |
| 21 | const host = process.env.VIMAX_WEB_HOST || '127.0.0.1'; |
| 22 | const port = Number(process.env.VIMAX_WEB_PORT || 4173); |
| 23 | const configuredUploadLimit = Number(process.env.VIMAX_WEB_UPLOAD_MAX_BYTES || 100 * 1024 * 1024); |
| 24 | const uploadMaxBytes = Number.isFinite(configuredUploadLimit) && configuredUploadLimit > 0 |
| 25 | ? configuredUploadLimit |
| 26 | : 100 * 1024 * 1024; |
| 27 | const subscribers = new Set(); |
| 28 | let agentProcess = null; |
| 29 | let activeSessionId = ''; |
| 30 | |
| 31 | let vite = null; |
| 32 | |
| 33 | const server = createServer(async (request, response) => { |
| 34 | const url = new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`); |
| 35 | try { |
| 36 | if (url.pathname === '/api/events' && request.method === 'GET') { |
| 37 | return openEventStream(request, response); |
| 38 | } |
| 39 | if (url.pathname === '/api/sessions' && request.method === 'GET') { |
| 40 | return sendJson(response, 200, await readSessionState(repoRoot)); |
| 41 | } |
| 42 | if (url.pathname === '/api/config' && request.method === 'GET') { |
| 43 | return sendJson(response, 200, await readAgentConfig(repoRoot)); |
| 44 | } |
| 45 | if (url.pathname === '/api/config' && request.method === 'PUT') { |
| 46 | const config = await saveAgentConfig(repoRoot, await readJsonBody(request)); |
| 47 | stopAgent('config'); |
| 48 | return sendJson(response, 200, config); |
| 49 | } |
| 50 | if (url.pathname === '/api/sessions' && request.method === 'DELETE') { |
| 51 | const sessionId = url.searchParams.get('session') || ''; |
| 52 | const current = await readSessionState(repoRoot); |
| 53 | if (!current.sessions.some((session) => session.sessionId === sessionId)) { |
| 54 | return sendJson(response, 404, {error: 'Project not found'}); |
| 55 | } |
| 56 | if (sessionId === activeSessionId) stopAgent('delete'); |
| 57 | const state = await deleteSession(repoRoot, sessionId); |
| 58 | activeSessionId = state.activeSessionId; |
| 59 | broadcast({type: 'sessions_changed', ...state}); |
| 60 | return sendJson(response, 200, state); |
| 61 | } |
| 62 | if (url.pathname === '/api/history' && request.method === 'GET') { |
| 63 | return sendJson(response, 200, {messages: await readSessionHistory(repoRoot, url.searchParams.get('session') || '')}); |
| 64 | } |
| 65 | if (url.pathname === '/api/artifacts' && request.method === 'GET') { |
| 66 | return sendJson(response, 200, {artifacts: await listSessionArtifacts(repoRoot, url.searchParams.get('session') || '')}); |
| 67 | } |
| 68 | if (url.pathname === '/api/artifact' && request.method === 'GET') { |
| 69 | return streamArtifact(response, url.searchParams.get('session') || '', url.searchParams.get('path') || ''); |
| 70 | } |
| 71 | if (url.pathname === '/api/uploads' && request.method === 'POST') { |
| 72 | const sessionId = url.searchParams.get('session') || ''; |
| 73 | const fileName = url.searchParams.get('name') || ''; |
| 74 | const current = await readSessionState(repoRoot); |
| 75 | if (!current.sessions.some((session) => session.sessionId === sessionId)) { |
| 76 | return sendJson(response, 404, {error: 'Project not found'}); |
| 77 | } |
| 78 | const declaredSize = Number(request.headers['content-length'] || 0); |
| 79 | if (declaredSize > uploadMaxBytes) { |
| 80 | return sendJson(response, 413, {error: `File exceeds the ${formatByteLimit(uploadMaxBytes)} upload limit`}); |
| 81 | } |
| 82 | const data = await readBinaryBody(request, uploadMaxBytes); |
| 83 | const file = await storeWorkspaceUpload(repoRoot, sessionId, fileName, data); |
| 84 | return sendJson(response, 201, {file}); |
| 85 | } |
| 86 | if (url.pathname === '/api/agent/start' && request.method === 'POST') { |
| 87 | const body = await readJsonBody(request); |
| 88 | const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''; |
| 89 | const projectName = typeof body.projectName === 'string' ? body.projectName.trim() : ''; |
| 90 | if (projectName.length > 64) { |
| 91 | return sendJson(response, 400, {error: 'Project name must be 64 characters or fewer'}); |
| 92 | } |
| 93 | await startAgent({newSession: body.newSession === true, sessionId, projectName}); |
| 94 | return sendJson(response, 200, {ok: true}); |
| 95 | } |
| 96 | if (url.pathname === '/api/messages' && request.method === 'POST') { |
| 97 | const body = await readJsonBody(request); |
| 98 | const text = String(body.text || '').trim(); |
| 99 | if (!text) return sendJson(response, 400, {error: 'Message text is required'}); |
| 100 | if (!agentProcess?.stdin.writable) return sendJson(response, 409, {error: 'Agent is not running'}); |
| 101 | agentProcess.stdin.write(`${text}\n`); |
| 102 | return sendJson(response, 202, {ok: true}); |
| 103 | } |
| 104 | if (url.pathname === '/api/agent/stop' && request.method === 'POST') { |
| 105 | stopAgent('user'); |
| 106 | return sendJson(response, 200, {ok: true}); |
| 107 | } |
| 108 | if (url.pathname === '/api/health' && request.method === 'GET') { |
| 109 | return sendJson(response, 200, {ok: true, agentRunning: Boolean(agentProcess), activeSessionId}); |
| 110 | } |
| 111 | if (url.pathname === '/assets/vimax.png' && request.method === 'GET') { |
| 112 | response.writeHead(200, {'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600'}); |
| 113 | createReadStream(path.join(repoRoot, 'assets', 'vimax.png')).pipe(response); |
| 114 | return; |
| 115 | } |
| 116 | if (vite) { |
| 117 | vite.middlewares(request, response, () => sendJson(response, 404, {error: 'Not found'})); |
| 118 | return; |
| 119 | } |
| 120 | return serveProductionApp(response, url.pathname); |
| 121 | } catch (error) { |
| 122 | const status = Number(error?.statusCode) || 500; |
| 123 | sendJson(response, status, {error: error instanceof Error ? error.message : String(error)}); |
| 124 | } |
| 125 | }); |
| 126 | |
| 127 | if (isDev) { |
| 128 | vite = await (await import('vite')).createServer({ |
| 129 | root: webRoot, |
| 130 | server: {middlewareMode: true, hmr: {server}}, |
| 131 | appType: 'spa', |
| 132 | }); |
| 133 | } |
| 134 | |
| 135 | server.listen(port, host, () => { |
| 136 | console.log(`ViMax Web: http://${host}:${port}`); |
| 137 | }); |
| 138 | |
| 139 | process.on('SIGINT', shutdown); |
| 140 | process.on('SIGTERM', shutdown); |
| 141 | |
| 142 | async function startAgent({newSession, sessionId, projectName = ''}) { |
| 143 | if (newSession && sessionId) throw new Error('Choose either a new or existing session'); |
| 144 | stopAgent('switch'); |
| 145 | const {command, args} = agentCommand(); |
| 146 | const sessionArgs = newSession |
| 147 | ? ['--new-session', ...(projectName ? ['--new-session-name', projectName] : [])] |
| 148 | : sessionId |
| 149 | ? ['--session', sessionId] |
| 150 | : []; |
| 151 | activeSessionId = sessionId; |
| 152 | const child = spawn(command, [...args, 'main_agent.py', '--jsonl', '--stdin-repl', ...sessionArgs], { |
| 153 | cwd: repoRoot, |
| 154 | env: process.env, |
| 155 | stdio: ['pipe', 'pipe', 'pipe'], |
| 156 | }); |
| 157 | agentProcess = child; |
| 158 | let childStdoutBuffer = ''; |
| 159 | broadcast({type: 'bridge_status', status: 'starting', message: newSession ? 'Creating workspace' : 'Opening workspace'}); |
| 160 | child.stdout.setEncoding('utf8'); |
| 161 | child.stdout.on('data', (chunk) => { |
| 162 | if (agentProcess !== child) return; |
| 163 | childStdoutBuffer += String(chunk); |
| 164 | const lines = childStdoutBuffer.split(/\r?\n/); |
| 165 | childStdoutBuffer = lines.pop() || ''; |
| 166 | for (const line of lines) consumeAgentLine(line); |
| 167 | }); |
| 168 | child.stderr.setEncoding('utf8'); |
| 169 | child.stderr.on('data', (chunk) => { |
| 170 | if (agentProcess !== child) return; |
| 171 | for (const line of String(chunk).split(/\r?\n/)) { |
| 172 | if (line.trim()) broadcast({type: 'terminal', stream: 'stderr', line}); |
| 173 | } |
| 174 | }); |
| 175 | child.on('error', (error) => { |
| 176 | if (agentProcess !== child) return; |
| 177 | broadcast({type: 'error', message: `Agent process error: ${error.message}`}); |
| 178 | }); |
| 179 | child.on('exit', (code, signal) => { |
| 180 | if (agentProcess !== child) return; |
| 181 | agentProcess = null; |
| 182 | broadcast({ |
| 183 | type: 'bridge_status', |
| 184 | status: code === 0 || signal === 'SIGTERM' ? 'stopped' : 'error', |
| 185 | message: signal ? `Agent stopped by ${signal}` : `Agent exited with code ${code ?? 0}`, |
| 186 | }); |
| 187 | }); |
| 188 | setTimeout(async () => { |
| 189 | if (agentProcess !== child) return; |
| 190 | const state = await readSessionState(repoRoot); |
| 191 | activeSessionId = state.activeSessionId || sessionId || activeSessionId; |
| 192 | broadcast({type: 'sessions_changed', ...state, activeSessionId}); |
| 193 | broadcast({type: 'bridge_status', status: 'ready', message: 'Agent ready'}); |
| 194 | }, 350); |
| 195 | } |
| 196 | |
| 197 | function consumeAgentLine(line) { |
| 198 | if (!line.trim()) return; |
| 199 | try { |
| 200 | const event = JSON.parse(line); |
| 201 | if (event.type === 'session') activeSessionId = event.session?.active_session_id || activeSessionId; |
| 202 | broadcast(event); |
| 203 | if (event.type === 'session') { |
| 204 | readSessionState(repoRoot).then((state) => broadcast({type: 'sessions_changed', ...state})); |
| 205 | } |
| 206 | } catch { |
| 207 | broadcast({type: 'terminal', stream: 'stdout', line}); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | function openEventStream(request, response) { |
| 212 | response.writeHead(200, { |
| 213 | 'Content-Type': 'text/event-stream', |
| 214 | 'Cache-Control': 'no-cache, no-transform', |
| 215 | Connection: 'keep-alive', |
| 216 | 'X-Accel-Buffering': 'no', |
| 217 | }); |
| 218 | response.write(`data: ${JSON.stringify({type: 'bridge_status', status: agentProcess ? 'ready' : 'idle', message: agentProcess ? 'Agent connected' : 'Agent idle'})}\n\n`); |
| 219 | subscribers.add(response); |
| 220 | const heartbeat = setInterval(() => response.write(': keepalive\n\n'), 15_000); |
| 221 | request.on('close', () => { |
| 222 | clearInterval(heartbeat); |
| 223 | subscribers.delete(response); |
| 224 | }); |
| 225 | } |
| 226 | |
| 227 | function broadcast(event) { |
| 228 | const payload = `data: ${JSON.stringify(event)}\n\n`; |
| 229 | for (const subscriber of subscribers) subscriber.write(payload); |
| 230 | } |
| 231 | |
| 232 | function stopAgent(reason) { |
| 233 | if (!agentProcess) return; |
| 234 | const child = agentProcess; |
| 235 | agentProcess = null; |
| 236 | child.kill('SIGTERM'); |
| 237 | const message = reason === 'switch' |
| 238 | ? 'Switching workspace' |
| 239 | : reason === 'config' |
| 240 | ? 'Configuration updated' |
| 241 | : 'Generation stopped'; |
| 242 | broadcast({type: 'bridge_status', status: 'stopped', message}); |
| 243 | } |
| 244 | |
| 245 | function agentCommand() { |
| 246 | if (process.env.VIMAX_AGENT_COMMAND) { |
| 247 | return {command: process.env.VIMAX_AGENT_COMMAND, args: splitArgs(process.env.VIMAX_AGENT_ARGS || '')}; |
| 248 | } |
| 249 | const configuredPython = process.env.VIMAX_PYTHON_CMD; |
| 250 | if (configuredPython) return {command: configuredPython, args: []}; |
| 251 | const bundledUv = process.env.VIMAX_UV_CMD || path.join(process.env.HOME || '', '.local', 'bin', 'uv'); |
| 252 | if (bundledUv && existsSync(bundledUv)) return {command: bundledUv, args: ['run', 'python']}; |
| 253 | const venvPython = path.join(repoRoot, '.venv', 'bin', 'python3'); |
| 254 | if (existsSync(venvPython)) return {command: venvPython, args: []}; |
| 255 | return {command: 'uv', args: ['run', 'python']}; |
| 256 | } |
| 257 | |
| 258 | function splitArgs(value) { |
| 259 | return value.split(/\s+/).map((part) => part.trim()).filter(Boolean); |
| 260 | } |
| 261 | |
| 262 | async function readJsonBody(request) { |
| 263 | const chunks = []; |
| 264 | for await (const chunk of request) chunks.push(chunk); |
| 265 | if (!chunks.length) return {}; |
| 266 | const text = Buffer.concat(chunks).toString('utf8'); |
| 267 | if (text.length > 1_000_000) throw new Error('Request body is too large'); |
| 268 | return JSON.parse(text); |
| 269 | } |
| 270 | |
| 271 | async function readBinaryBody(request, maxBytes) { |
| 272 | const chunks = []; |
| 273 | let size = 0; |
| 274 | for await (const chunk of request) { |
| 275 | size += chunk.length; |
| 276 | if (size > maxBytes) { |
| 277 | const error = new Error(`File exceeds the ${formatByteLimit(maxBytes)} upload limit`); |
| 278 | error.statusCode = 413; |
| 279 | throw error; |
| 280 | } |
| 281 | chunks.push(chunk); |
| 282 | } |
| 283 | return Buffer.concat(chunks, size); |
| 284 | } |
| 285 | |
| 286 | function formatByteLimit(bytes) { |
| 287 | return `${Math.max(1, Math.round(bytes / (1024 * 1024)))} MB`; |
| 288 | } |
| 289 | |
| 290 | function sendJson(response, status, payload) { |
| 291 | if (response.writableEnded) return; |
| 292 | response.writeHead(status, {'Content-Type': 'application/json; charset=utf-8'}); |
| 293 | response.end(JSON.stringify(payload)); |
| 294 | } |
| 295 | |
| 296 | async function streamArtifact(response, sessionId, relativePath) { |
| 297 | const filePath = resolveArtifactPath(repoRoot, sessionId, relativePath); |
| 298 | if (!existsSync(filePath)) return sendJson(response, 404, {error: 'Artifact not found'}); |
| 299 | response.writeHead(200, { |
| 300 | 'Content-Type': artifactContentType(filePath), |
| 301 | 'Cache-Control': 'private, max-age=60', |
| 302 | }); |
| 303 | createReadStream(filePath).pipe(response); |
| 304 | } |
| 305 | |
| 306 | async function serveProductionApp(response, pathname) { |
| 307 | const requested = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); |
| 308 | const candidate = path.resolve(webRoot, 'dist', requested); |
| 309 | const distRoot = path.resolve(webRoot, 'dist'); |
| 310 | const safeCandidate = candidate.startsWith(`${distRoot}${path.sep}`) ? candidate : path.join(distRoot, 'index.html'); |
| 311 | const filePath = existsSync(safeCandidate) ? safeCandidate : path.join(distRoot, 'index.html'); |
| 312 | const body = await readFile(filePath); |
| 313 | response.writeHead(200, {'Content-Type': artifactContentType(filePath)}); |
| 314 | response.end(body); |
| 315 | } |
| 316 | |
| 317 | function shutdown() { |
| 318 | stopAgent('shutdown'); |
| 319 | server.close(() => process.exit(0)); |
| 320 | setTimeout(() => process.exit(0), 1_000).unref(); |
| 321 | } |
| 322 |