React hook
React apps usually need three pieces at once:
- a local route that starts or adopts the camera server
- a lifecycle layer that handles polling + SSE + reconnect
- component state that renders camera/session data cleanly
The real TypeScript example app in this repo does not hide all of that behind one giant library call. It keeps the moving pieces explicit in:
example_app/src/app/api/server/route.tsexample_app/src/lib/event-stream.tsexample_app/src/lib/camera-manager.tsexample_app/src/app/page.tsx
This recipe shows the same pattern as a reusable hook.
When to use
Section titled “When to use”- Next.js / React apps that want to embed the server lifecycle
- Apps that should auto-discover and auto-connect cameras
- Apps that want typed action helpers without owning raw SSE parsing everywhere
When NOT to use
Section titled “When NOT to use”- Scripts or one-off tools — call the REST API directly
- Non-React apps — use the discovery/SSE recipes directly
Hook pattern
Section titled “Hook pattern”import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { CameraManager, type ManagedCamera } from "./camera-manager";
type ServerState = "starting" | "running" | "error";
export interface CameraSessionState { serverState: ServerState; serverError: string | null; cameras: ManagedCamera[]; selectedCameraId: string | null;}
export interface CameraSessionActions { selectCamera: (cameraId: string) => void; refreshCameras: () => void; disconnectCamera: () => Promise<void>;}
export function useCameraSession(): CameraSessionState & CameraSessionActions { const [serverState, setServerState] = useState<ServerState>("starting"); const [serverError, setServerError] = useState<string | null>(null); const [cameras, setCameras] = useState<ManagedCamera[]>([]); const [selectedCameraId, setSelectedCameraId] = useState<string | null>(null);
const managerRef = useRef<CameraManager | null>(null); const selectedCameraIdRef = useRef<string | null>(null); selectedCameraIdRef.current = selectedCameraId;
const syncCameras = useCallback(() => { const manager = managerRef.current; if (!manager) return; const next = manager.getCameras(); setCameras(next); }, []);
useEffect(() => { let cancelled = false;
(async () => { try { const response = await fetch("/api/server", { method: "POST" }); const payload = await response.json();
if (cancelled) return; if (payload.status !== "started" && payload.status !== "already_running") { setServerState("error"); setServerError(payload.message ?? "Failed to start server"); return; }
const manager = new CameraManager({ baseUrl: `http://localhost:${payload.port}`, autoConnect: true, connectionMode: "remote-transfer", pollInterval: 5000, autoReconnect: true, });
managerRef.current = manager;
manager.on("camera-found", ({ camera }) => { syncCameras(); if (!selectedCameraIdRef.current) { setSelectedCameraId(camera.id); } });
manager.on("camera-lost", () => { syncCameras(); });
manager.on("camera-ready", ({ cameraId }) => { syncCameras(); if (!selectedCameraIdRef.current) { setSelectedCameraId(cameraId); } });
manager.on("camera-disconnected", () => { syncCameras(); });
manager.on("connection-failed", () => { syncCameras(); });
manager.on("error", ({ message }) => { setServerError(message); });
await manager.start(); if (cancelled) return;
syncCameras(); setServerState("running"); } catch (error) { if (cancelled) return; setServerState("error"); setServerError(error instanceof Error ? error.message : String(error)); } })();
return () => { cancelled = true; managerRef.current?.close(); managerRef.current = null; }; }, [syncCameras]);
const disconnectCamera = useCallback(async () => { const manager = managerRef.current; const cameraId = selectedCameraIdRef.current; if (!manager || !cameraId) return; await manager.disconnect(cameraId); syncCameras(); }, [syncCameras]);
return useMemo( () => ({ serverState, serverError, cameras, selectedCameraId, selectCamera: setSelectedCameraId, refreshCameras: syncCameras, disconnectCamera, }), [serverState, serverError, cameras, selectedCameraId, syncCameras, disconnectCamera] );}import { useCameraSession } from "./use-camera-session";
export function CameraSidebar() { const { serverState, serverError, cameras, selectedCameraId, selectCamera, refreshCameras, } = useCameraSession();
if (serverState === "starting") return <div>Starting camera server…</div>; if (serverState === "error") return <div>Error: {serverError}</div>;
return ( <div> <button onClick={refreshCameras}>Refresh</button> {cameras.map((camera) => ( <button key={camera.info.id} onClick={() => selectCamera(camera.info.id)} style={{ display: "block", fontWeight: selectedCameraId === camera.info.id ? "bold" : "normal", }} > {camera.info.model} — {camera.state} </button> ))} </div> );}Why this matches the real app
Section titled “Why this matches the real app”- The browser layer starts the server through
POST /api/server, not by spawning a binary directly in client code. CameraManagerowns polling, SSE, connect timing, reconnect, and post-connect setup.- React components stay focused on state + rendering, not transport details.
That is the same boundary the repo’s actual Next.js example uses.