mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-30 02:44:25 +01:00
perf: refactor sse handleing
This commit is contained in:
168
src/lib/api/queries.ts
Normal file
168
src/lib/api/queries.ts
Normal 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
25
src/lib/sse/SSEContext.ts
Normal 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
48
src/lib/sse/callSSE.ts
Normal 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;
|
||||
};
|
||||
8
src/lib/sse/components/SSEProvider.tsx
Normal file
8
src/lib/sse/components/SSEProvider.tsx
Normal 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>;
|
||||
};
|
||||
110
src/lib/sse/components/ServerEventsProvider.tsx
Normal file
110
src/lib/sse/components/ServerEventsProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/lib/sse/hook/useServerEventListener.ts
Normal file
24
src/lib/sse/hook/useServerEventListener.ts
Normal 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 ?? [])]);
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
7
src/lib/sse/store/sseAtom.ts
Normal file
7
src/lib/sse/store/sseAtom.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const sseAtom = atom<{
|
||||
isConnected: boolean;
|
||||
}>({
|
||||
isConnected: false,
|
||||
});
|
||||
Reference in New Issue
Block a user