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:
5
.changeset/fix-dashboard-realtime-updates.md
Normal file
5
.changeset/fix-dashboard-realtime-updates.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dashboard real-time updates not reaching the browser. SSE event pipeline now handles write errors gracefully, cleans up zombie connections, and reconnects automatically when the connection silently dies.
|
||||||
@@ -4388,4 +4388,87 @@ Task with acceptance criteria
|
|||||||
expect(detail.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
|
expect(detail.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("event emissions", () => {
|
||||||
|
it("createTask emits task:created with the new task", async () => {
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:created", (t: any) => events.push(t));
|
||||||
|
const task = await store.createTask({ description: "event test" });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(task.id);
|
||||||
|
expect(events[0].description).toBe("event test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask emits task:moved with from/to columns", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:moved", (data: any) => events.push(data));
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].from).toBe("triage");
|
||||||
|
expect(events[0].to).toBe("todo");
|
||||||
|
expect(events[0].task.id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateTask emits task:updated with the updated task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.updateTask(task.id, { title: "Updated" });
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(events.some((e: any) => e.title === "Updated")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauseTask emits task:updated", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(events.some((e: any) => e.paused === true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateStep emits task:updated", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.updateStep(task.id, 0, "in-progress");
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask emits task:deleted", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:deleted", (t: any) => events.push(t));
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logEntry emits task:updated", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.logEntry(task.id, "test action", "test outcome");
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cache is updated when polling is active even without fs.watch", async () => {
|
||||||
|
// Start watching which enables polling
|
||||||
|
await store.watch();
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Verify the cache was updated (internal detail: the isWatching getter)
|
||||||
|
// Move the task and verify the event fires correctly (not duplicated by poll)
|
||||||
|
const movedEvents: any[] = [];
|
||||||
|
store.on("task:moved", (data: any) => movedEvents.push(data));
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
expect(movedEvents).toHaveLength(1);
|
||||||
|
expect(movedEvents[0].from).toBe("triage");
|
||||||
|
expect(movedEvents[0].to).toBe("todo");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
/** Last known modification timestamp for change detection */
|
/** Last known modification timestamp for change detection */
|
||||||
private lastKnownModified: number = 0;
|
private lastKnownModified: number = 0;
|
||||||
|
|
||||||
|
/** Whether the store is actively watching for changes (watcher or polling). */
|
||||||
|
private get isWatching(): boolean {
|
||||||
|
return this.watcher !== null || this.pollInterval !== null;
|
||||||
|
}
|
||||||
/** Cached MissionStore instance */
|
/** Cached MissionStore instance */
|
||||||
private missionStore: MissionStore | null = null;
|
private missionStore: MissionStore | null = null;
|
||||||
|
|
||||||
@@ -697,7 +702,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
const heading = task.title ? `${id}: ${task.title}` : id;
|
const heading = task.title ? `${id}: ${task.title}` : id;
|
||||||
const prompt = task.column === "triage"
|
const prompt = task.column === "triage"
|
||||||
@@ -751,7 +756,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
|
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(newId, { ...newTask });
|
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||||
|
|
||||||
this.emit("task:created", newTask);
|
this.emit("task:created", newTask);
|
||||||
return newTask;
|
return newTask;
|
||||||
@@ -826,7 +831,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(newId, { ...newTask });
|
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||||
|
|
||||||
this.emit("task:created", newTask);
|
this.emit("task:created", newTask);
|
||||||
return newTask;
|
return newTask;
|
||||||
@@ -922,7 +927,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
|
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
|
||||||
return task;
|
return task;
|
||||||
@@ -1041,7 +1046,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
if (updates.prompt !== undefined) {
|
if (updates.prompt !== undefined) {
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
@@ -1104,7 +1109,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -1161,7 +1166,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -1189,7 +1194,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -1296,7 +1301,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
this.db.bumpLastModified();
|
this.db.bumpLastModified();
|
||||||
|
|
||||||
// Remove from cache if watcher is active
|
// Remove from cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.delete(id);
|
if (this.isWatching) this.taskCache.delete(id);
|
||||||
|
|
||||||
// Delete directory from disk
|
// Delete directory from disk
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
@@ -1579,7 +1584,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
|
||||||
// Remove from cache if watcher is active
|
// Remove from cache if watcher is active
|
||||||
if (this.watcher) {
|
if (this.isWatching) {
|
||||||
this.taskCache.delete(id);
|
this.taskCache.delete(id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1587,7 +1592,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
||||||
@@ -1651,7 +1656,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
|
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
|
||||||
return task;
|
return task;
|
||||||
@@ -1669,7 +1674,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
// Update cache if watcher is active
|
||||||
if (this.watcher) this.taskCache.set(task.id, { ...task });
|
if (this.isWatching) this.taskCache.set(task.id, { ...task });
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
|
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
|
||||||
}
|
}
|
||||||
@@ -1924,7 +1929,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
@@ -1979,7 +1984,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
@@ -2054,7 +2059,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
currentTask.updatedAt = new Date().toISOString();
|
currentTask.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, currentTask);
|
await this.atomicWriteTaskJson(dir, currentTask);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...currentTask });
|
if (this.isWatching) this.taskCache.set(id, { ...currentTask });
|
||||||
|
|
||||||
this.emit("task:updated", currentTask);
|
this.emit("task:updated", currentTask);
|
||||||
return currentTask;
|
return currentTask;
|
||||||
@@ -2084,7 +2089,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -2110,7 +2115,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -2163,7 +2168,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
return task;
|
return task;
|
||||||
@@ -2242,7 +2247,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
if (badgeChanged) {
|
if (badgeChanged) {
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
@@ -2306,7 +2311,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await this.atomicWriteTaskJson(dir, task);
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
if (badgeChanged) {
|
if (badgeChanged) {
|
||||||
this.emit("task:updated", task);
|
this.emit("task:updated", task);
|
||||||
@@ -2425,7 +2430,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
|
||||||
// Remove from cache if watcher is active
|
// Remove from cache if watcher is active
|
||||||
if (this.watcher) {
|
if (this.isWatching) {
|
||||||
this.taskCache.delete(task.id);
|
this.taskCache.delete(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
describe("cleanup", () => {
|
||||||
it("closes EventSource on unmount", async () => {
|
it("closes EventSource on unmount", async () => {
|
||||||
mockFetchTasks.mockResolvedValueOnce([]);
|
mockFetchTasks.mockResolvedValueOnce([]);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
|||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
const RECONNECT_DELAY_MS = 3000;
|
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 {
|
function normalizeTask(task: Task): Task {
|
||||||
return {
|
return {
|
||||||
@@ -98,13 +100,29 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let closedByCleanup = false;
|
let closedByCleanup = false;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
if (connectionNonce > 0) {
|
if (connectionNonce > 0) {
|
||||||
void refreshTasks();
|
void refreshTasks();
|
||||||
}
|
}
|
||||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
const es = new EventSource(`/api/events${query}`);
|
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) => {
|
const handleCreated = (e: MessageEvent) => {
|
||||||
|
resetHeartbeat();
|
||||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||||
// In project mode, only add if this task belongs to our project
|
// 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
|
// 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) => {
|
const handleMoved = (e: MessageEvent) => {
|
||||||
|
resetHeartbeat();
|
||||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||||
const normalizedTask = normalizeTask(task);
|
const normalizedTask = normalizeTask(task);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
@@ -127,6 +146,7 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdated = (e: MessageEvent) => {
|
const handleUpdated = (e: MessageEvent) => {
|
||||||
|
resetHeartbeat();
|
||||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) => {
|
prev.map((t) => {
|
||||||
@@ -156,11 +176,13 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleted = (e: MessageEvent) => {
|
const handleDeleted = (e: MessageEvent) => {
|
||||||
|
resetHeartbeat();
|
||||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMerged = (e: MessageEvent) => {
|
const handleMerged = (e: MessageEvent) => {
|
||||||
|
resetHeartbeat();
|
||||||
const { task }: { task: Task } = JSON.parse(e.data);
|
const { task }: { task: Task } = JSON.parse(e.data);
|
||||||
const normalizedTask = normalizeTask(task);
|
const normalizedTask = normalizeTask(task);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
@@ -175,7 +197,12 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearTimeout(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
es.removeEventListener("heartbeat", handleHeartbeat);
|
||||||
es.removeEventListener("task:created", handleCreated);
|
es.removeEventListener("task:created", handleCreated);
|
||||||
es.removeEventListener("task:moved", handleMoved);
|
es.removeEventListener("task:moved", handleMoved);
|
||||||
es.removeEventListener("task:updated", handleUpdated);
|
es.removeEventListener("task:updated", handleUpdated);
|
||||||
@@ -194,6 +221,11 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
}, RECONNECT_DELAY_MS);
|
}, 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:created", handleCreated);
|
||||||
es.addEventListener("task:moved", handleMoved);
|
es.addEventListener("task:moved", handleMoved);
|
||||||
es.addEventListener("task:updated", handleUpdated);
|
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;
|
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) {
|
export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
||||||
return (_req: Request, res: Response) => {
|
return (_req: Request, res: Response) => {
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
@@ -21,95 +36,11 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
|||||||
// Send initial heartbeat
|
// Send initial heartbeat
|
||||||
res.write(": connected\n\n");
|
res.write(": connected\n\n");
|
||||||
|
|
||||||
const onCreated = (task: any) => {
|
/** Detach all listeners and clean up. Idempotent. */
|
||||||
res.write(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
|
let cleaned = false;
|
||||||
};
|
const cleanup = () => {
|
||||||
const onMoved = (data: any) => {
|
if (cleaned) return;
|
||||||
res.write(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
|
cleaned = true;
|
||||||
};
|
|
||||||
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", () => {
|
|
||||||
activeConnections--;
|
activeConnections--;
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
store.off("task:created", onCreated);
|
store.off("task:created", onCreated);
|
||||||
@@ -133,6 +64,104 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
|||||||
missionStore.off("feature:deleted", onFeatureDeleted);
|
missionStore.off("feature:deleted", onFeatureDeleted);
|
||||||
missionStore.off("feature:linked", onFeatureLinked);
|
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