Tesseract / studio /src /hooks /useProcesses.js
rkbosamia's picture
Tesseract major version upgrade - v2
9c49532
Raw
History Blame Contribute Delete
6.16 kB
/**
* 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 }
}