feat(frontend): useSessionStream (EventSource -> store)

This commit is contained in:
2026-06-28 12:19:55 +02:00
parent ebd3988634
commit d238ddd395
3 changed files with 75 additions and 0 deletions
@@ -0,0 +1,28 @@
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStream } from "./useSessionStream";
import { useSessionStore } from "../store/sessionStore";
beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
useSessionStore.getState().resetSession();
});
test("opens an EventSource for the session and feeds events to the store", () => {
renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0];
expect(es.url).toContain("/sessions/s1/events");
act(() =>
es.emit({ type: "ui_request", ui_request: { id: "u1", widget: "select" } })
);
expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
});
test("closes the stream on unmount", () => {
const { unmount } = renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0];
unmount();
expect(es.closed).toBe(true);
});
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { BASE } from "../api/client";
import { useSessionStore } from "../store/sessionStore";
import type { StreamEvent } from "../api/types";
export function useSessionStream(sessionId: string | null) {
const [connected, setConnected] = useState(false);
const applyEvent = useSessionStore((s) => s.applyEvent);
useEffect(() => {
if (!sessionId) return;
const es = new EventSource(`${BASE}/sessions/${sessionId}/events`);
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
es.onmessage = (ev) => {
try {
applyEvent(JSON.parse(ev.data) as StreamEvent);
} catch {
/* ignore malformed */
}
};
return () => {
es.close();
setConnected(false);
};
}, [sessionId, applyEvent]);
return { connected };
}
+16
View File
@@ -0,0 +1,16 @@
export class FakeEventSource {
static instances: FakeEventSource[] = [];
onmessage: ((e: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(public url: string) {
FakeEventSource.instances.push(this);
}
emit(obj: unknown) {
this.onmessage?.({ data: JSON.stringify(obj) });
}
close() {
this.closed = true;
}
}