Canvas: Implement streaming state updates

Change-Id: I2bc5a51b5792839bde93f927f5ffea22b3250fe2
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index cd5f29c..0132721 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -209,6 +209,7 @@
 	githubRepositories: GitHubRepository[];
 	githubRepositoriesLoading: boolean;
 	githubRepositoriesError: string | null;
+	stateEventSource: EventSource | null;
 	setHighlightCategory: (name: string, active: boolean) => void;
 	onNodesChange: OnNodesChange<AppNode>;
 	onEdgesChange: OnEdgesChange;
@@ -409,24 +410,6 @@
 		});
 	};
 
-	const restoreSaved = async () => {
-		const { projectId } = get();
-		const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
-			method: "GET",
-		});
-		const inst = await resp.json();
-		setN(inst.state.nodes);
-		set({ edges: inst.state.edges });
-		injectNetworkNodes();
-		if (
-			get().zoom.x !== inst.state.viewport.x ||
-			get().zoom.y !== inst.state.viewport.y ||
-			get().zoom.zoom !== inst.state.viewport.zoom
-		) {
-			set({ zoom: inst.state.viewport });
-		}
-	};
-
 	function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
 		setN(
 			get().nodes.map((n) => {
@@ -629,6 +612,43 @@
 		}
 	};
 
+	const disconnectFromStateStream = () => {
+		const { stateEventSource } = get();
+		if (stateEventSource) {
+			stateEventSource.close();
+			set({ stateEventSource: null });
+		}
+	};
+
+	const connectToStateStream = (projectId: string, mode: "deploy" | "edit") => {
+		disconnectFromStateStream();
+
+		const eventSource = new EventSource(
+			`/api/project/${projectId}/state/stream/${mode === "edit" ? "draft" : "deploy"}`,
+		);
+		set({ stateEventSource: eventSource });
+
+		eventSource.onmessage = (event) => {
+			const inst = JSON.parse(event.data);
+			setN(inst.nodes);
+			set({ edges: inst.edges });
+			injectNetworkNodes();
+			if (
+				get().zoom.x !== inst.viewport.x ||
+				get().zoom.y !== inst.viewport.y ||
+				get().zoom.zoom !== inst.viewport.zoom
+			) {
+				set({ zoom: inst.viewport });
+			}
+		};
+
+		eventSource.onerror = (err) => {
+			console.error("EventSource failed:", err);
+			eventSource.close();
+			set({ stateEventSource: null });
+		};
+	};
+
 	return {
 		projectId: undefined,
 		mode: "edit",
@@ -654,6 +674,7 @@
 		githubRepositories: [],
 		githubRepositoriesLoading: false,
 		githubRepositoriesError: null,
+		stateEventSource: null,
 		setViewport: (viewport) => {
 			const { viewport: vp } = get();
 			if (
@@ -780,7 +801,12 @@
 			}
 		},
 		setMode: (mode) => {
+			disconnectFromStateStream();
 			set({ mode });
+			const projectId = get().projectId;
+			if (projectId) {
+				connectToStateStream(projectId, mode);
+			}
 		},
 		setProject: async (projectId) => {
 			const currentProjectId = get().projectId;
@@ -788,6 +814,7 @@
 				return;
 			}
 			stopRefreshEnvInterval();
+			disconnectFromStateStream();
 			set({
 				projectId,
 				githubRepositories: [],
@@ -801,7 +828,8 @@
 				} else {
 					set({ mode: "edit" });
 				}
-				restoreSaved();
+				const mode = get().mode;
+				connectToStateStream(projectId, mode);
 				startRefreshEnvInterval();
 			} else {
 				set({