/** * useProcesses — dev-server lifecycle for the active project. * * Split into two: * * useProcessesConnection() — mount ONCE (App.jsx). Owns the WebSocket to * /api/projects/{id}/logs and the status poller. Both are driven off * Zustand state, so any component can flip status and the connection * reacts. * * useProcesses() — callable from anywhere. Returns * { start, stop, reload, status }. Pure dispatchers — no effects, no * refs, no duplicated timers. TopBar, PreviewPanel, command palette, * and slash commands all use this. */ import { useEffect, useRef, useCallback } from 'react' import useAppStore from '../store/appStore' const POLL_INTERVAL_MS = 2000 const POLLING_STATES = new Set(['installing', 'starting', 'stopping']) // ── 1. Connection hook ───────────────────────────────────────────────────── export function useProcessesConnection() { const projectId = useAppStore((s) => s.project.id) const status = useAppStore((s) => s.runtime.status) const setRuntime = useAppStore((s) => s.setRuntime) const appendLog = useAppStore((s) => s.appendLog) const wsRef = useRef(null) const pollRef = useRef(null) // WebSocket — re-mounts whenever projectId changes. useEffect(() => { if (!projectId) return // Effect-scoped flag so the onclose-reconnect doesn't fire a new WS after // cleanup runs (e.g. logout tears down Studio → effect cleanup → ws.close // → onclose → setTimeout reconnect that lands AFTER unmount). Also // guards against reconnect storms when the server rejects us with 403 // (no auth cookie) — a closed-with-error from a 403 was triggering an // unbounded 2 s reconnect loop visible as a stream of 403s in the logs. let cancelled = false let reconnectTimer = null let consecutiveFails = 0 function connect() { if (cancelled) return wsRef.current?.close() const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const ws = new WebSocket( `${proto}://${window.location.host}/api/projects/${projectId}/logs` ) let opened = false ws.onopen = () => { opened = true consecutiveFails = 0 } ws.onmessage = (e) => { try { const msg = JSON.parse(e.data) if (msg.kind !== 'ping' && msg.line !== undefined) { appendLog({ kind: msg.kind, line: msg.line }) } } catch { /* ignore */ } } ws.onclose = () => { if (cancelled) return if (useAppStore.getState().project.id !== projectId) return // If the socket never opened, it was rejected at handshake (e.g. 403 // because the cookie's gone). Back off exponentially and give up // after a few tries instead of hammering the server. if (!opened) { consecutiveFails += 1 if (consecutiveFails >= 5) return const delay = Math.min(15_000, 1000 * 2 ** (consecutiveFails - 1)) reconnectTimer = setTimeout(connect, delay) } else { reconnectTimer = setTimeout(connect, 2000) } } wsRef.current = ws } connect() return () => { cancelled = true if (reconnectTimer) clearTimeout(reconnectTimer) wsRef.current?.close() wsRef.current = null } }, [projectId, appendLog]) // Polling — only runs while status is transitional. useEffect(() => { if (!projectId || !POLLING_STATES.has(status)) return pollRef.current = setInterval(async () => { try { const res = await fetch(`/api/projects/${projectId}/processes`) if (!res.ok) return const data = await res.json() const { overall, frontend, backend } = data const prev = useAppStore.getState().runtime setRuntime({ status: overall, frontendStatus: frontend.status, backendStatus: backend.status, frontendPort: frontend.port ?? prev.frontendPort, backendPort: backend.port ?? prev.backendPort, }) } catch { /* keep polling */ } }, POLL_INTERVAL_MS) return () => { clearInterval(pollRef.current) pollRef.current = null } }, [projectId, status, setRuntime]) } // ── 2. Actions hook ──────────────────────────────────────────────────────── export function useProcesses() { const projectId = useAppStore((s) => s.project.id) const status = useAppStore((s) => s.runtime.status) const setRuntime = useAppStore((s) => s.setRuntime) const clearLogs = useAppStore((s) => s.clearLogs) const start = useCallback(async () => { if (!projectId) return clearLogs() setRuntime({ status: 'installing' }) try { const res = await fetch(`/api/projects/${projectId}/processes/start`, { method: 'POST' }) const data = await res.json() if (!res.ok) { setRuntime({ status: 'error' }) return } setRuntime({ status: data.overall === 'running' ? 'running' : 'starting', frontendPort: data.frontend_port, backendPort: data.backend_port, }) } catch { setRuntime({ status: 'error' }) } }, [projectId, clearLogs, setRuntime]) const stop = useCallback(async () => { if (!projectId) return setRuntime({ status: 'stopping' }) try { await fetch(`/api/projects/${projectId}/processes/stop`, { method: 'POST' }) } catch { /* ignore */ } setRuntime({ status: 'idle', frontendPort: null, backendPort: null, frontendStatus: 'idle', backendStatus: 'idle', }) }, [projectId, setRuntime]) const reload = useCallback(() => { const k = useAppStore.getState().runtime.iframeKey ?? 0 setRuntime({ iframeKey: k + 1 }) }, [setRuntime]) return { start, stop, reload, status } }