fix(FN-732): fix dashboard real-time updates and SSE pipeline
- Fix SSE event relay to properly broadcast task store events to dashboard clients - Use named heartbeat events instead of SSE comments for reliable keep-alive - Add missing event emission in core task store for state changes - Add comprehensive tests for SSE pipeline, event emission, and UI hooks - Remove broken useTerminal hook and AgentLogViewer tests, fix flaky test suites
This commit is contained in:
@@ -628,6 +628,99 @@ describe("useTasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat timeout", () => {
|
||||
it("reconnects when no SSE messages arrive within 45 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Advance past the 45s heartbeat timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(45_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// First connection should be closed
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
|
||||
// After reconnect delay (3s), a new connection should be created
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not reconnect when heartbeat events arrive regularly", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Simulate heartbeat every 30s (before the 45s timeout)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
first._emit("heartbeat");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
first._emit("heartbeat");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Should still be on the first connection
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(first.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("resets heartbeat timeout on task events", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Advance 40s (close to timeout)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(40_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Send a task event to reset the watchdog
|
||||
act(() => {
|
||||
first._emit("task:updated", createMockTask({ id: "FN-001" }));
|
||||
});
|
||||
|
||||
// Advance another 40s (would have timed out without the reset)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(40_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Should still be on the first connection
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(first.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("closes EventSource on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
/** If no SSE message (including heartbeat events) arrives within this window, force reconnect. */
|
||||
const HEARTBEAT_TIMEOUT_MS = 45_000;
|
||||
|
||||
function normalizeTask(task: Task): Task {
|
||||
return {
|
||||
@@ -98,13 +100,29 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (connectionNonce > 0) {
|
||||
void refreshTasks();
|
||||
}
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
/** Reset the heartbeat watchdog. Called on every incoming SSE message. */
|
||||
const resetHeartbeat = () => {
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = setTimeout(() => {
|
||||
// No message received within the timeout — connection is likely dead.
|
||||
if (!closedByCleanup) {
|
||||
handleError();
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Start the watchdog immediately — if the connection never opens we still want to time out.
|
||||
resetHeartbeat();
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
// In project mode, only add if this task belongs to our project
|
||||
// Since we can't determine project from event, we add and let subsequent
|
||||
@@ -117,6 +135,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -127,6 +146,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -156,11 +176,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||
};
|
||||
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -175,7 +197,12 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
es.removeEventListener("heartbeat", handleHeartbeat);
|
||||
es.removeEventListener("task:created", handleCreated);
|
||||
es.removeEventListener("task:moved", handleMoved);
|
||||
es.removeEventListener("task:updated", handleUpdated);
|
||||
@@ -194,6 +221,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}, RECONNECT_DELAY_MS);
|
||||
};
|
||||
|
||||
/** Server heartbeat (named event, not comment) — just resets the watchdog. */
|
||||
const handleHeartbeat = () => { resetHeartbeat(); };
|
||||
|
||||
es.addEventListener("open", () => resetHeartbeat());
|
||||
es.addEventListener("heartbeat", handleHeartbeat);
|
||||
es.addEventListener("task:created", handleCreated);
|
||||
es.addEventListener("task:moved", handleMoved);
|
||||
es.addEventListener("task:updated", handleUpdated);
|
||||
|
||||
178
packages/dashboard/src/__tests__/sse.test.ts
Normal file
178
packages/dashboard/src/__tests__/sse.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Response, Request } from "express";
|
||||
import { createSSE, getActiveSSEConnections } from "../sse.js";
|
||||
|
||||
/** Minimal mock TaskStore — just needs EventEmitter behaviour. */
|
||||
function createMockStore() {
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(50);
|
||||
return emitter as any;
|
||||
}
|
||||
|
||||
/** Create a mock Express response with a writeable buffer. */
|
||||
function createMockResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
flushHeaders: vi.fn(),
|
||||
write: vi.fn((data: string) => {
|
||||
chunks.push(data);
|
||||
return true;
|
||||
}),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
} as unknown as Response;
|
||||
return { res, chunks };
|
||||
}
|
||||
|
||||
/** Create a mock Express request that can fire 'close'. */
|
||||
function createMockRequest() {
|
||||
const emitter = new EventEmitter();
|
||||
return emitter as unknown as Request;
|
||||
}
|
||||
|
||||
describe("createSSE", () => {
|
||||
let store: ReturnType<typeof createMockStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
it("writes initial connected comment", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
expect(chunks[0]).toBe(": connected\n\n");
|
||||
});
|
||||
|
||||
it("relays task:created events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const task = { id: "FN-001", description: "test" };
|
||||
store.emit("task:created", task);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:created"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain(JSON.stringify(task));
|
||||
});
|
||||
|
||||
it("relays task:moved events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const data = { task: { id: "FN-001" }, from: "triage", to: "todo" };
|
||||
store.emit("task:moved", data);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:moved"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain(JSON.stringify(data));
|
||||
});
|
||||
|
||||
it("relays task:updated events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const task = { id: "FN-001", title: "Updated" };
|
||||
store.emit("task:updated", task);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:updated"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it("relays task:deleted events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const task = { id: "FN-001" };
|
||||
store.emit("task:deleted", task);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it("relays task:merged events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const result = { task: { id: "FN-001" }, success: true };
|
||||
store.emit("task:merged", result);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:merged"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up listeners when client disconnects", () => {
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const before = store.listenerCount("task:created");
|
||||
expect(before).toBe(1);
|
||||
|
||||
// Simulate client disconnect
|
||||
req.emit("close");
|
||||
|
||||
expect(store.listenerCount("task:created")).toBe(0);
|
||||
expect(store.listenerCount("task:moved")).toBe(0);
|
||||
expect(store.listenerCount("task:updated")).toBe(0);
|
||||
expect(store.listenerCount("task:deleted")).toBe(0);
|
||||
expect(store.listenerCount("task:merged")).toBe(0);
|
||||
});
|
||||
|
||||
it("stops writing when response is destroyed", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
// Mark response as destroyed
|
||||
(res as any).destroyed = true;
|
||||
|
||||
const initialCount = chunks.length;
|
||||
store.emit("task:created", { id: "FN-001" });
|
||||
|
||||
// No new chunks should be written
|
||||
expect(chunks.length).toBe(initialCount);
|
||||
});
|
||||
|
||||
it("stops writing and cleans up when res.write throws", () => {
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
// Make write throw on next call
|
||||
(res.write as any).mockImplementation(() => {
|
||||
throw new Error("Socket closed");
|
||||
});
|
||||
|
||||
// This should not throw — the error is caught internally
|
||||
expect(() => store.emit("task:created", { id: "FN-001" })).not.toThrow();
|
||||
|
||||
// Listeners should be cleaned up
|
||||
expect(store.listenerCount("task:created")).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks active connection count", () => {
|
||||
const req1 = createMockRequest();
|
||||
const { res: res1 } = createMockResponse();
|
||||
const req2 = createMockRequest();
|
||||
const { res: res2 } = createMockResponse();
|
||||
|
||||
const initial = getActiveSSEConnections();
|
||||
createSSE(store)(req1, res1);
|
||||
expect(getActiveSSEConnections()).toBe(initial + 1);
|
||||
createSSE(store)(req2, res2);
|
||||
expect(getActiveSSEConnections()).toBe(initial + 2);
|
||||
|
||||
req1.emit("close");
|
||||
expect(getActiveSSEConnections()).toBe(initial + 1);
|
||||
req2.emit("close");
|
||||
expect(getActiveSSEConnections()).toBe(initial);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,21 @@ export function getActiveSSEConnections(): number {
|
||||
return activeConnections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely write to an SSE response stream.
|
||||
* Returns `true` if the write succeeded, `false` if the connection is dead.
|
||||
* On failure the caller should clean up event listeners.
|
||||
*/
|
||||
function safeWrite(res: Response, data: string): boolean {
|
||||
try {
|
||||
if (res.writableEnded || res.destroyed) return false;
|
||||
res.write(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
||||
return (_req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
@@ -21,95 +36,11 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
||||
// Send initial heartbeat
|
||||
res.write(": connected\n\n");
|
||||
|
||||
const onCreated = (task: any) => {
|
||||
res.write(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMoved = (data: any) => {
|
||||
res.write(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onUpdated = (task: any) => {
|
||||
res.write(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onDeleted = (task: any) => {
|
||||
res.write(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMerged = (result: any) => {
|
||||
res.write(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
|
||||
};
|
||||
|
||||
store.on("task:created", onCreated);
|
||||
store.on("task:moved", onMoved);
|
||||
store.on("task:updated", onUpdated);
|
||||
store.on("task:deleted", onDeleted);
|
||||
store.on("task:merged", onMerged);
|
||||
|
||||
// Mission store event listeners (only wired up when missionStore is provided)
|
||||
const onMissionCreated = (data: any) => {
|
||||
res.write(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionUpdated = (data: any) => {
|
||||
res.write(`event: mission:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionDeleted = (data: any) => {
|
||||
res.write(`event: mission:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneCreated = (data: any) => {
|
||||
res.write(`event: milestone:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneUpdated = (data: any) => {
|
||||
res.write(`event: milestone:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneDeleted = (data: any) => {
|
||||
res.write(`event: milestone:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceCreated = (data: any) => {
|
||||
res.write(`event: slice:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceUpdated = (data: any) => {
|
||||
res.write(`event: slice:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceDeleted = (data: any) => {
|
||||
res.write(`event: slice:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceActivated = (data: any) => {
|
||||
res.write(`event: slice:activated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureCreated = (data: any) => {
|
||||
res.write(`event: feature:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureUpdated = (data: any) => {
|
||||
res.write(`event: feature:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureDeleted = (data: any) => {
|
||||
res.write(`event: feature:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureLinked = (data: any) => {
|
||||
res.write(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
if (missionStore) {
|
||||
missionStore.on("mission:created", onMissionCreated);
|
||||
missionStore.on("mission:updated", onMissionUpdated);
|
||||
missionStore.on("mission:deleted", onMissionDeleted);
|
||||
missionStore.on("milestone:created", onMilestoneCreated);
|
||||
missionStore.on("milestone:updated", onMilestoneUpdated);
|
||||
missionStore.on("milestone:deleted", onMilestoneDeleted);
|
||||
missionStore.on("slice:created", onSliceCreated);
|
||||
missionStore.on("slice:updated", onSliceUpdated);
|
||||
missionStore.on("slice:deleted", onSliceDeleted);
|
||||
missionStore.on("slice:activated", onSliceActivated);
|
||||
missionStore.on("feature:created", onFeatureCreated);
|
||||
missionStore.on("feature:updated", onFeatureUpdated);
|
||||
missionStore.on("feature:deleted", onFeatureDeleted);
|
||||
missionStore.on("feature:linked", onFeatureLinked);
|
||||
}
|
||||
|
||||
// Heartbeat every 30s to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
_req.on("close", () => {
|
||||
/** Detach all listeners and clean up. Idempotent. */
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
activeConnections--;
|
||||
clearInterval(heartbeat);
|
||||
store.off("task:created", onCreated);
|
||||
@@ -133,6 +64,104 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
||||
missionStore.off("feature:deleted", onFeatureDeleted);
|
||||
missionStore.off("feature:linked", onFeatureLinked);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Write an SSE message; clean up on failure. */
|
||||
const send = (data: string) => {
|
||||
if (!safeWrite(res, data)) cleanup();
|
||||
};
|
||||
|
||||
const onCreated = (task: any) => {
|
||||
send(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMoved = (data: any) => {
|
||||
send(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onUpdated = (task: any) => {
|
||||
send(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onDeleted = (task: any) => {
|
||||
send(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMerged = (result: any) => {
|
||||
send(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
|
||||
};
|
||||
|
||||
store.on("task:created", onCreated);
|
||||
store.on("task:moved", onMoved);
|
||||
store.on("task:updated", onUpdated);
|
||||
store.on("task:deleted", onDeleted);
|
||||
store.on("task:merged", onMerged);
|
||||
|
||||
// Mission store event listeners (only wired up when missionStore is provided)
|
||||
const onMissionCreated = (data: any) => {
|
||||
send(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionUpdated = (data: any) => {
|
||||
send(`event: mission:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionDeleted = (data: any) => {
|
||||
send(`event: mission:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneCreated = (data: any) => {
|
||||
send(`event: milestone:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneUpdated = (data: any) => {
|
||||
send(`event: milestone:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneDeleted = (data: any) => {
|
||||
send(`event: milestone:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceCreated = (data: any) => {
|
||||
send(`event: slice:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceUpdated = (data: any) => {
|
||||
send(`event: slice:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceDeleted = (data: any) => {
|
||||
send(`event: slice:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceActivated = (data: any) => {
|
||||
send(`event: slice:activated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureCreated = (data: any) => {
|
||||
send(`event: feature:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureUpdated = (data: any) => {
|
||||
send(`event: feature:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureDeleted = (data: any) => {
|
||||
send(`event: feature:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureLinked = (data: any) => {
|
||||
send(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
if (missionStore) {
|
||||
missionStore.on("mission:created", onMissionCreated);
|
||||
missionStore.on("mission:updated", onMissionUpdated);
|
||||
missionStore.on("mission:deleted", onMissionDeleted);
|
||||
missionStore.on("milestone:created", onMilestoneCreated);
|
||||
missionStore.on("milestone:updated", onMilestoneUpdated);
|
||||
missionStore.on("milestone:deleted", onMilestoneDeleted);
|
||||
missionStore.on("slice:created", onSliceCreated);
|
||||
missionStore.on("slice:updated", onSliceUpdated);
|
||||
missionStore.on("slice:deleted", onSliceDeleted);
|
||||
missionStore.on("slice:activated", onSliceActivated);
|
||||
missionStore.on("feature:created", onFeatureCreated);
|
||||
missionStore.on("feature:updated", onFeatureUpdated);
|
||||
missionStore.on("feature:deleted", onFeatureDeleted);
|
||||
missionStore.on("feature:linked", onFeatureLinked);
|
||||
}
|
||||
|
||||
// Heartbeat every 30s to keep connection alive.
|
||||
// Sent as a named event so the client's EventSource can detect it
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
// fire event listeners in the browser).
|
||||
const heartbeat = setInterval(() => {
|
||||
send("event: heartbeat\ndata: \n\n");
|
||||
}, 30_000);
|
||||
|
||||
_req.on("close", cleanup);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user