perf: refactor sse handleing

This commit is contained in:
d-kimsuon
2025-09-18 20:42:44 +09:00
parent a90ef520dd
commit eb5a8ddeeb
38 changed files with 727 additions and 597 deletions

168
src/lib/api/queries.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion";
import { honoClient } from "./client";
export const projectListQuery = {
queryKey: ["projects"],
queryFn: async () => {
const response = await honoClient.api.projects.$get({
param: {},
});
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.statusText}`);
}
return await response.json();
},
} as const;
export const projectDetailQuery = (projectId: string) =>
({
queryKey: ["projects", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch project: ${response.statusText}`);
}
return await response.json();
},
}) as const;
export const sessionDetailQuery = (projectId: string, sessionId: string) =>
({
queryKey: ["projects", projectId, "sessions", sessionId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].$get({
param: {
projectId,
sessionId,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch session: ${response.statusText}`);
}
return response.json();
},
}) as const;
export const claudeCommandsQuery = (projectId: string) =>
({
queryKey: ["claude-commands", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"][
"claude-commands"
].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(
`Failed to fetch claude commands: ${response.statusText}`,
);
}
return await response.json();
},
}) as const;
export const aliveTasksQuery = {
queryKey: ["aliveTasks"],
queryFn: async () => {
const response = await honoClient.api.tasks.alive.$get({});
if (!response.ok) {
throw new Error(`Failed to fetch alive tasks: ${response.statusText}`);
}
return await response.json();
},
} as const;
export const gitBranchesQuery = (projectId: string) =>
({
queryKey: ["git", "branches", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.branches.$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch branches: ${response.statusText}`);
}
return await response.json();
},
}) as const;
export const gitCommitsQuery = (projectId: string) =>
({
queryKey: ["git", "commits", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.commits.$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch commits: ${response.statusText}`);
}
return await response.json();
},
}) as const;
export const mcpListQuery = {
queryKey: ["mcp", "list"],
queryFn: async () => {
const response = await honoClient.api.mcp.list.$get();
if (!response.ok) {
throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
}
return await response.json();
},
} as const;
export const fileCompletionQuery = (projectId: string, basePath: string) =>
({
queryKey: ["file-completion", projectId, basePath],
queryFn: async (): Promise<FileCompletionResult> => {
const response = await honoClient.api.projects[":projectId"][
"file-completion"
].$get({
param: { projectId },
query: { basePath },
});
if (!response.ok) {
throw new Error("Failed to fetch file completion");
}
return response.json();
},
}) as const;
export const configQuery = {
queryKey: ["config"],
queryFn: async () => {
const response = await honoClient.api.config.$get();
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.statusText}`);
}
return await response.json();
},
} as const;

25
src/lib/sse/SSEContext.ts Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import { createContext, useContext } from "react";
import type { SSEEvent } from "../../types/sse";
export type EventListener<T extends SSEEvent["kind"]> = (
event: Extract<SSEEvent, { kind: T }>,
) => void;
export type SSEContextType = {
addEventListener: <T extends SSEEvent["kind"]>(
eventType: T,
listener: EventListener<T>,
) => () => void;
};
export const SSEContext = createContext<SSEContextType | null>(null);
export const useSSEContext = () => {
const context = useContext(SSEContext);
if (!context) {
throw new Error("useSSEContext must be used within SSEProvider");
}
return context;
};

48
src/lib/sse/callSSE.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { SSEEventMap } from "../../types/sse";
export const callSSE = () => {
const eventSource = new EventSource(
new URL("/api/sse", window.location.origin).href,
);
const handleOnOpen = (event: Event) => {
console.log("SSE connection opened", event);
};
eventSource.onopen = handleOnOpen;
const addEventListener = <EventName extends keyof SSEEventMap>(
eventName: EventName,
listener: (event: SSEEventMap[EventName]) => void,
) => {
const callbackFn = (event: MessageEvent) => {
try {
const sseEvent: SSEEventMap[EventName] = JSON.parse(event.data);
listener(sseEvent);
} catch (error) {
console.error("Failed to parse SSE event data:", error);
}
};
eventSource.addEventListener(eventName, callbackFn);
const removeEventListener = () => {
eventSource.removeEventListener(eventName, callbackFn);
};
return {
removeEventListener,
} as const;
};
const cleanUp = () => {
eventSource.onopen = null;
eventSource.onmessage = null;
eventSource.close();
};
return {
addEventListener,
cleanUp,
eventSource,
} as const;
};

View File

@@ -0,0 +1,8 @@
"use client";
import type { FC, PropsWithChildren } from "react";
import { ServerEventsProvider } from "./ServerEventsProvider";
export const SSEProvider: FC<PropsWithChildren> = ({ children }) => {
return <ServerEventsProvider>{children}</ServerEventsProvider>;
};

View File

@@ -0,0 +1,110 @@
import { useAtom } from "jotai";
import {
type FC,
type PropsWithChildren,
useCallback,
useEffect,
useRef,
} from "react";
import type { SSEEvent } from "../../../types/sse";
import { callSSE } from "../callSSE";
import {
type EventListener,
SSEContext,
type SSEContextType,
} from "../SSEContext";
import { sseAtom } from "../store/sseAtom";
export const ServerEventsProvider: FC<PropsWithChildren> = ({ children }) => {
const sseRef = useRef<ReturnType<typeof callSSE> | null>(null);
const listenersRef = useRef<
Map<SSEEvent["kind"], Set<(event: SSEEvent) => void>>
>(new Map());
const [, setSSEState] = useAtom(sseAtom);
useEffect(() => {
const sse = callSSE();
sseRef.current = sse;
const { removeEventListener } = sse.addEventListener("connect", (event) => {
setSSEState({
isConnected: true,
});
console.log("SSE connected", event);
});
return () => {
// clean up
sse.cleanUp();
removeEventListener();
};
}, [setSSEState]);
const addEventListener = useCallback(
<T extends SSEEvent["kind"]>(eventType: T, listener: EventListener<T>) => {
// Store the listener in our internal map
if (!listenersRef.current.has(eventType)) {
listenersRef.current.set(eventType, new Set());
}
const listeners = listenersRef.current.get(eventType);
if (listeners) {
listeners.add(listener as (event: SSEEvent) => void);
}
// Register with the actual SSE connection
let sseCleanup: (() => void) | null = null;
let timeoutId: NodeJS.Timeout | null = null;
const registerWithSSE = () => {
if (sseRef.current) {
const { removeEventListener } = sseRef.current.addEventListener(
eventType,
(event) => {
// The listener expects the specific event type, so we cast it through unknown first
listener(event as unknown as Extract<SSEEvent, { kind: T }>);
},
);
sseCleanup = removeEventListener;
}
};
// Register immediately if SSE is ready, or wait for it
if (sseRef.current) {
registerWithSSE();
} else {
// Use a small delay to wait for SSE to be initialized
timeoutId = setTimeout(registerWithSSE, 0);
}
// Return cleanup function
return () => {
// Remove from internal listeners
const listeners = listenersRef.current.get(eventType);
if (listeners) {
listeners.delete(listener as (event: SSEEvent) => void);
if (listeners.size === 0) {
listenersRef.current.delete(eventType);
}
}
// Remove from SSE connection
if (sseCleanup) {
sseCleanup();
}
// Clear timeout if it exists
if (timeoutId) {
clearTimeout(timeoutId);
}
};
},
[],
);
const contextValue: SSEContextType = {
addEventListener,
};
return (
<SSEContext.Provider value={contextValue}>{children}</SSEContext.Provider>
);
};

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
import type { SSEEvent } from "../../../types/sse";
import { type EventListener, useSSEContext } from "../SSEContext";
/**
* Custom hook to listen for specific SSE events
* @param eventType - The type of event to listen for
* @param listener - The callback function to execute when the event is received
* @param deps - Dependencies array for the listener function (similar to useEffect)
*/
export const useServerEventListener = <T extends SSEEvent["kind"]>(
eventType: T,
listener: EventListener<T>,
deps?: React.DependencyList,
) => {
const { addEventListener } = useSSEContext();
useEffect(() => {
const removeEventListener = addEventListener(eventType, listener);
return () => {
removeEventListener();
};
}, [eventType, addEventListener, listener, ...(deps ?? [])]);
};

View File

@@ -1,110 +0,0 @@
import type {
ProjectChangedData,
SessionChangedData,
} from "../../server/service/events/types";
export interface SSEEventHandlers {
onProjectChanged?: (data: ProjectChangedData) => void;
onSessionChanged?: (data: SessionChangedData) => void;
onConnected?: () => void;
onHeartbeat?: (timestamp: string) => void;
onError?: (error: Event) => void;
onClose?: () => void;
}
export class SSEClient {
private eventSource: EventSource | null = null;
private handlers: SSEEventHandlers;
private url: string;
constructor(baseUrl: string = "", handlers: SSEEventHandlers = {}) {
this.url = `${baseUrl}/api/events`;
this.handlers = handlers;
}
public connect(): void {
if (this.eventSource) {
this.disconnect();
}
try {
this.eventSource = new EventSource(this.url);
// 接続確認イベント
this.eventSource.addEventListener("connected", (event) => {
console.log("SSE Connected:", event.data);
this.handlers.onConnected?.();
});
// プロジェクト変更イベント
this.eventSource.addEventListener("project_changed", (event) => {
try {
const data: ProjectChangedData = JSON.parse(event.data);
console.log("Project changed:", data);
this.handlers.onProjectChanged?.(data);
} catch (error) {
console.error("Failed to parse project_changed event:", error);
}
});
// セッション変更イベント
this.eventSource.addEventListener("session_changed", (event) => {
try {
const data: SessionChangedData = JSON.parse(event.data);
console.log("Session changed:", data);
this.handlers.onSessionChanged?.(data);
} catch (error) {
console.error("Failed to parse session_changed event:", error);
}
});
// ハートビートイベント
this.eventSource.addEventListener("heartbeat", (event) => {
try {
const data = JSON.parse(event.data);
this.handlers.onHeartbeat?.(data.timestamp);
} catch (error) {
console.error("Failed to parse heartbeat event:", error);
}
});
// エラーハンドリング
this.eventSource.onerror = (error) => {
console.error("SSE Error:", error);
this.handlers.onError?.(error);
};
// 接続終了
this.eventSource.onopen = () => {
console.log("SSE Connection opened");
};
} catch (error) {
console.error("Failed to establish SSE connection:", error);
this.handlers.onError?.(error as Event);
}
}
public disconnect(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
console.log("SSE Connection closed");
this.handlers.onClose?.();
}
}
public isConnected(): boolean {
return this.eventSource?.readyState === EventSource.OPEN;
}
}
// React Hook example
export function useSSE(handlers: SSEEventHandlers) {
const client = new SSEClient(window?.location?.origin, handlers);
return {
connect: () => client.connect(),
disconnect: () => client.disconnect(),
isConnected: () => client.isConnected(),
};
}

View File

@@ -0,0 +1,7 @@
import { atom } from "jotai";
export const sseAtom = atom<{
isConnected: boolean;
}>({
isConnected: false,
});