diff --git a/packages/frontend/src/hooks/useWebSocket.ts b/packages/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..e4c0d16 --- /dev/null +++ b/packages/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useCallback, useState } from "react"; + +interface ForgeEvent { + type: string; + action: string; + data: unknown; + timestamp: number; +} + +type EventHandler = (event: ForgeEvent) => void; + +export function useWebSocket() { + const wsRef = useRef(null); + const handlersRef = useRef>>(new Map()); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${protocol}//${window.location.host}/ws`; + + function connect() { + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => setConnected(true); + ws.onclose = () => { + setConnected(false); + setTimeout(connect, 2000); + }; + ws.onerror = () => ws.close(); + + ws.onmessage = (msg) => { + try { + const event: ForgeEvent = JSON.parse(msg.data); + const key = `${event.type}:${event.action}`; + const wildcard = `${event.type}:*`; + + for (const k of [key, wildcard]) { + const handlers = handlersRef.current.get(k); + if (handlers) { + for (const handler of handlers) handler(event); + } + } + } catch { + // ignore malformed messages + } + }; + } + + connect(); + return () => wsRef.current?.close(); + }, []); + + const subscribe = useCallback((typeAction: string, handler: EventHandler) => { + if (!handlersRef.current.has(typeAction)) { + handlersRef.current.set(typeAction, new Set()); + } + handlersRef.current.get(typeAction)!.add(handler); + return () => { + handlersRef.current.get(typeAction)?.delete(handler); + }; + }, []); + + return { connected, subscribe }; +} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts new file mode 100644 index 0000000..2a37263 --- /dev/null +++ b/packages/frontend/src/services/api.ts @@ -0,0 +1,38 @@ +const BASE = "/api"; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(body.error || res.statusText); + } + return res.json(); +} + +export const api = { + health: () => request<{ status: string; wsClients: number }>("/health"), + + workspace: { + getConfig: () => request>("/workspace/config"), + updateConfig: (data: Record) => + request("/workspace/config", { method: "PUT", body: JSON.stringify(data) }), + init: () => request("/workspace/init", { method: "POST" }), + }, + + docker: { + containers: () => request>>("/docker/containers"), + pull: (image: string) => + request("/docker/pull", { method: "POST", body: JSON.stringify({ image }) }), + start: (name: string) => + request(`/docker/containers/${name}/start`, { method: "POST" }), + stop: (name: string) => + request(`/docker/containers/${name}/stop`, { method: "POST" }), + restart: (name: string) => + request(`/docker/containers/${name}/restart`, { method: "POST" }), + logs: (name: string, tail = 100) => + request<{ logs: string }>(`/docker/containers/${name}/logs?tail=${tail}`), + }, +};