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

View File

@@ -0,0 +1,33 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import type { FC, PropsWithChildren } from "react";
import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries";
import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener";
import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
const setAliveTasks = useSetAtom(aliveTasksAtom);
useServerEventListener("sessionListChanged", async (event) => {
// invalidate session list
await queryClient.invalidateQueries({
queryKey: projectDetailQuery(event.projectId).queryKey,
});
});
useServerEventListener("sessionChanged", async (event) => {
// invalidate session detail
await queryClient.invalidateQueries({
queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey,
});
});
useServerEventListener("taskChanged", async (event) => {
setAliveTasks(event.aliveTasks);
});
return <>{children}</>;
};

View File

@@ -1,13 +0,0 @@
"use client";
import { useServerEvents } from "@/hooks/useServerEvents";
interface ServerEventsProviderProps {
children: React.ReactNode;
}
export function ServerEventsProvider({ children }: ServerEventsProviderProps) {
useServerEvents();
return <>{children}</>;
}

View File

@@ -5,21 +5,15 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useCallback } from "react"; import { useCallback } from "react";
import { honoClient } from "../../lib/api/client"; import { honoClient } from "../../lib/api/client";
import { configQuery } from "../../lib/api/queries";
import type { Config } from "../../server/config/config"; import type { Config } from "../../server/config/config";
export const configQueryConfig = {
queryKey: ["config"],
queryFn: async () => {
const response = await honoClient.api.config.$get();
return await response.json();
},
} as const;
export const useConfig = () => { export const useConfig = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useSuspenseQuery({ const { data } = useSuspenseQuery({
...configQueryConfig, queryKey: configQuery.queryKey,
queryFn: configQuery.queryFn,
}); });
const updateConfigMutation = useMutation({ const updateConfigMutation = useMutation({
mutationFn: async (config: Config) => { mutationFn: async (config: Config) => {
@@ -30,7 +24,7 @@ export const useConfig = () => {
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey, queryKey: configQuery.queryKey,
}); });
}, },
}); });

View File

@@ -1,13 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper"; import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { RootErrorBoundary } from "./components/RootErrorBoundary"; import { RootErrorBoundary } from "./components/RootErrorBoundary";
import { ServerEventsProvider } from "./components/ServerEventsProvider";
import "./globals.css"; import "./globals.css";
import { QueryClient } from "@tanstack/react-query"; import { configQuery } from "../lib/api/queries";
import { configQueryConfig } from "./hooks/useConfig"; import { SSEEventListeners } from "./components/SSEEventListeners";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
@@ -35,7 +37,8 @@ export default async function RootLayout({
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
...configQueryConfig, queryKey: configQuery.queryKey,
queryFn: configQuery.queryFn,
}); });
return ( return (
@@ -45,7 +48,9 @@ export default async function RootLayout({
> >
<RootErrorBoundary> <RootErrorBoundary>
<QueryClientProviderWrapper> <QueryClientProviderWrapper>
<ServerEventsProvider>{children}</ServerEventsProvider> <SSEProvider>
<SSEEventListeners>{children}</SSEEventListeners>
</SSEProvider>
</QueryClientProviderWrapper> </QueryClientProviderWrapper>
</RootErrorBoundary> </RootErrorBoundary>
<Toaster position="top-right" /> <Toaster position="top-right" />

View File

@@ -25,8 +25,9 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { projectDetailQuery } from "../../../../lib/api/queries";
import { useConfig } from "../../../hooks/useConfig"; import { useConfig } from "../../../hooks/useConfig";
import { projectQueryConfig, useProject } from "../hooks/useProject"; import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle"; import { firstCommandToTitle } from "../services/firstCommandToTitle";
import { NewChatModal } from "./newChat/NewChatModal"; import { NewChatModal } from "./newChat/NewChatModal";
@@ -41,7 +42,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
useEffect(() => { useEffect(() => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: projectQueryConfig(projectId).queryKey, queryKey: projectDetailQuery(projectId).queryKey,
}); });
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]);

View File

@@ -15,7 +15,7 @@ import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
} from "../../../../../components/ui/collapsible"; } from "../../../../../components/ui/collapsible";
import { honoClient } from "../../../../../lib/api/client"; import { claudeCommandsQuery } from "../../../../../lib/api/queries";
import { cn } from "../../../../../lib/utils"; import { cn } from "../../../../../lib/utils";
type CommandCompletionProps = { type CommandCompletionProps = {
@@ -40,18 +40,8 @@ export const CommandCompletion = forwardRef<
// コマンドリストを取得 // コマンドリストを取得
const { data: commandData } = useQuery({ const { data: commandData } = useQuery({
queryKey: ["claude-commands", projectId], queryKey: claudeCommandsQuery(projectId).queryKey,
queryFn: async () => { queryFn: claudeCommandsQuery(projectId).queryFn,
const response = await honoClient.api.projects[":projectId"][
"claude-commands"
].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error("Failed to fetch commands");
}
return response.json();
},
staleTime: 1000 * 60 * 5, // 5分間キャッシュ staleTime: 1000 * 60 * 5, // 5分間キャッシュ
}); });

View File

@@ -1,21 +1,10 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../../lib/api/client"; import { projectDetailQuery } from "../../../../lib/api/queries";
export const projectQueryConfig = (projectId: string) =>
({
queryKey: ["projects", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId },
});
return await response.json();
},
}) as const;
export const useProject = (projectId: string) => { export const useProject = (projectId: string) => {
return useSuspenseQuery({ return useSuspenseQuery({
...projectQueryConfig(projectId), queryKey: projectDetailQuery(projectId).queryKey,
queryFn: projectDetailQuery(projectId).queryFn,
refetchOnReconnect: true, refetchOnReconnect: true,
}); });
}; };

View File

@@ -3,8 +3,8 @@ import {
HydrationBoundary, HydrationBoundary,
QueryClient, QueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { projectDetailQuery } from "../../../lib/api/queries";
import { ProjectPageContent } from "./components/ProjectPage"; import { ProjectPageContent } from "./components/ProjectPage";
import { projectQueryConfig } from "./hooks/useProject";
interface ProjectPageProps { interface ProjectPageProps {
params: Promise<{ projectId: string }>; params: Promise<{ projectId: string }>;
@@ -16,7 +16,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
...projectQueryConfig(projectId), queryKey: projectDetailQuery(projectId).queryKey,
queryFn: projectDetailQuery(projectId).queryFn,
}); });
return ( return (

View File

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react"; import { RefreshCwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { honoClient } from "@/lib/api/client"; import { mcpListQuery } from "../../../../../../../lib/api/queries";
export const McpTab: FC = () => { export const McpTab: FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -14,18 +14,12 @@ export const McpTab: FC = () => {
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ["mcp", "list"], queryKey: mcpListQuery.queryKey,
queryFn: async () => { queryFn: mcpListQuery.queryFn,
const response = await honoClient.api.mcp.list.$get();
if (!response.ok) {
throw new Error("Failed to fetch MCP servers");
}
return response.json();
},
}); });
const handleReload = () => { const handleReload = () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "list"] }); queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
}; };
return ( return (

View File

@@ -1,24 +1,18 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { honoClient } from "../../../../../../lib/api/client"; import { aliveTasksQuery } from "../../../../../../lib/api/queries";
import { aliveTasksAtom } from "../store/aliveTasksAtom"; import { aliveTasksAtom } from "../store/aliveTasksAtom";
export const useAliveTask = (sessionId: string) => { export const useAliveTask = (sessionId: string) => {
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom); const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
useQuery({ useQuery({
queryKey: ["aliveTasks"], queryKey: aliveTasksQuery.queryKey,
queryFn: async () => { queryFn: async () => {
const response = await honoClient.api.tasks.alive.$get({}); const { aliveTasks } = await aliveTasksQuery.queryFn();
setAliveTasks(aliveTasks);
if (!response.ok) { return aliveTasks;
throw new Error(response.statusText);
}
const data = await response.json();
setAliveTasks(data.aliveTasks);
return response.json();
}, },
refetchOnReconnect: true, refetchOnReconnect: true,
}); });

View File

@@ -1,42 +1,22 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { honoClient } from "@/lib/api/client"; import { honoClient } from "@/lib/api/client";
import {
gitBranchesQuery,
gitCommitsQuery,
} from "../../../../../../lib/api/queries";
export const useGitBranches = (projectId: string) => { export const useGitBranches = (projectId: string) => {
return useQuery({ return useQuery({
queryKey: ["git", "branches", projectId], queryKey: gitBranchesQuery(projectId).queryKey,
queryFn: async () => { queryFn: gitBranchesQuery(projectId).queryFn,
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 response.json();
},
staleTime: 30000, // 30 seconds staleTime: 30000, // 30 seconds
}); });
}; };
export const useGitCommits = (projectId: string) => { export const useGitCommits = (projectId: string) => {
return useQuery({ return useQuery({
queryKey: ["git", "commits", projectId], queryKey: gitCommitsQuery(projectId).queryKey,
queryFn: async () => { queryFn: gitCommitsQuery(projectId).queryFn,
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 response.json();
},
staleTime: 30000, // 30 seconds staleTime: 30000, // 30 seconds
}); });
}; };

View File

@@ -1,25 +1,9 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../../../../lib/api/client"; import { sessionDetailQuery } from "../../../../../../lib/api/queries";
export const sessionQueryConfig = (projectId: string, sessionId: string) =>
({
queryKey: ["sessions", sessionId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].$get({
param: {
projectId,
sessionId,
},
});
return response.json();
},
}) as const;
export const useSessionQuery = (projectId: string, sessionId: string) => { export const useSessionQuery = (projectId: string, sessionId: string) => {
return useSuspenseQuery({ return useSuspenseQuery({
...sessionQueryConfig(projectId, sessionId), queryKey: sessionDetailQuery(projectId, sessionId).queryKey,
queryFn: sessionDetailQuery(projectId, sessionId).queryFn,
}); });
}; };

View File

@@ -1,8 +1,10 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { projectQueryConfig } from "../../hooks/useProject"; import {
projectDetailQuery,
sessionDetailQuery,
} from "../../../../../lib/api/queries";
import { SessionPageContent } from "./components/SessionPageContent"; import { SessionPageContent } from "./components/SessionPageContent";
import { sessionQueryConfig } from "./hooks/useSessionQuery";
type PageParams = { type PageParams = {
projectId: string; projectId: string;
@@ -19,11 +21,12 @@ export async function generateMetadata({
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
...sessionQueryConfig(projectId, sessionId), ...sessionDetailQuery(projectId, sessionId),
}); });
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
...projectQueryConfig(projectId), queryKey: projectDetailQuery(projectId).queryKey,
queryFn: projectDetailQuery(projectId).queryFn,
}); });
return { return {

View File

@@ -14,7 +14,9 @@ import {
import { useProjects } from "../hooks/useProjects"; import { useProjects } from "../hooks/useProjects";
export const ProjectList: FC = () => { export const ProjectList: FC = () => {
const { data: projects } = useProjects(); const {
data: { projects },
} = useProjects();
if (projects.length === 0) { if (projects.length === 0) {
<Card> <Card>

View File

@@ -1,18 +1,9 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../lib/api/client"; import { projectListQuery } from "../../../lib/api/queries";
export const projetsQueryConfig = {
queryKey: ["projects"],
queryFn: async () => {
const response = await honoClient.api.projects.$get();
const { projects } = await response.json();
return projects;
},
} as const;
export const useProjects = () => { export const useProjects = () => {
return useSuspenseQuery({ return useSuspenseQuery({
queryKey: projetsQueryConfig.queryKey, queryKey: projectListQuery.queryKey,
queryFn: projetsQueryConfig.queryFn, queryFn: projectListQuery.queryFn,
}); });
}; };

View File

@@ -1,7 +1,7 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { HistoryIcon } from "lucide-react"; import { HistoryIcon } from "lucide-react";
import { projectListQuery } from "../../lib/api/queries";
import { ProjectList } from "./components/ProjectList"; import { ProjectList } from "./components/ProjectList";
import { projetsQueryConfig } from "./hooks/useProjects";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
@@ -10,8 +10,8 @@ export default async function ProjectsPage() {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: projetsQueryConfig.queryKey, queryKey: projectListQuery.queryKey,
queryFn: projetsQueryConfig.queryFn, queryFn: projectListQuery.queryFn,
}); });
return ( return (

View File

@@ -2,7 +2,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type FC, useCallback, useId } from "react"; import { type FC, useCallback, useId } from "react";
import { configQueryConfig, useConfig } from "@/app/hooks/useConfig"; import { useConfig } from "@/app/hooks/useConfig";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
Select, Select,
@@ -11,7 +11,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject"; import {
configQuery,
projectDetailQuery,
projectListQuery,
} from "../lib/api/queries";
interface SettingsControlsProps { interface SettingsControlsProps {
openingProjectId: string; openingProjectId: string;
@@ -33,13 +37,13 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
const onConfigChanged = useCallback(async () => { const onConfigChanged = useCallback(async () => {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey, queryKey: configQuery.queryKey,
}); });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["projects"], queryKey: projectListQuery.queryKey,
}); });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: projectQueryConfig(openingProjectId).queryKey, queryKey: projectDetailQuery(openingProjectId).queryKey,
}); });
}, [queryClient, openingProjectId]); }, [queryClient, openingProjectId]);

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { honoClient } from "../lib/api/client"; import { fileCompletionQuery } from "../lib/api/queries";
export type FileCompletionEntry = { export type FileCompletionEntry = {
name: string; name: string;
@@ -19,21 +19,8 @@ export const useFileCompletion = (
enabled = true, enabled = true,
) => { ) => {
return useQuery({ return useQuery({
queryKey: ["file-completion", projectId, basePath], queryKey: fileCompletionQuery(projectId, basePath).queryKey,
queryFn: async (): Promise<FileCompletionResult> => { queryFn: fileCompletionQuery(projectId, basePath).queryFn,
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();
},
enabled: enabled && !!projectId, enabled: enabled && !!projectId,
staleTime: 1000 * 60 * 5, // 5分間キャッシュ staleTime: 1000 * 60 * 5, // 5分間キャッシュ
}); });

View File

@@ -1,113 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { aliveTasksAtom } from "../app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
import { projetsQueryConfig } from "../app/projects/hooks/useProjects";
import { honoClient } from "../lib/api/client";
import type { SSEEvent } from "../server/service/events/types";
type ParsedEvent = {
event: string;
data: SSEEvent;
id: string;
};
const parseSSEEvent = (text: string): ParsedEvent => {
const lines = text.split("\n");
const eventIndex = lines.findIndex((line) => line.startsWith("event:"));
const dataIndex = lines.findIndex((line) => line.startsWith("data:"));
const idIndex = lines.findIndex((line) => line.startsWith("id:"));
const endIndex = (index: number) => {
const targets = [eventIndex, dataIndex, idIndex, lines.length].filter(
(current) => current > index,
);
return Math.min(...targets);
};
if (eventIndex === -1 || dataIndex === -1 || idIndex === -1) {
console.error("failed", text);
throw new Error("Failed to parse SSE event");
}
const event = lines.slice(eventIndex, endIndex(eventIndex)).join("\n");
const data = lines.slice(dataIndex, endIndex(dataIndex)).join("\n");
const id = lines.slice(idIndex, endIndex(idIndex)).join("\n");
return {
id: id.slice("id:".length).trim(),
event: event.slice("event:".length).trim(),
data: JSON.parse(
data.slice(data.indexOf("{"), data.lastIndexOf("}") + 1),
) as SSEEvent,
};
};
const parseSSEEvents = (text: string): ParsedEvent[] => {
const eventTexts = text
.split("\n\n")
.filter((eventText) => eventText.length > 0);
return eventTexts.map((eventText) => parseSSEEvent(eventText));
};
let isInitialized = false;
export const useServerEvents = () => {
const queryClient = useQueryClient();
const setAliveTasks = useSetAtom(aliveTasksAtom);
const listener = useCallback(async () => {
console.log("listening to events");
const response = await honoClient.api.events.state_changes.$get();
if (!response.ok) {
throw new Error("Failed to fetch events");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Failed to get reader");
}
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const events = parseSSEEvents(decoder.decode(value));
for (const event of events) {
console.log("data", event);
if (event.data.type === "project_changed") {
await queryClient.invalidateQueries({
queryKey: projetsQueryConfig.queryKey,
});
}
if (event.data.type === "session_changed") {
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
}
if (event.data.type === "task_changed") {
setAliveTasks(event.data.data);
}
}
}
}, [queryClient, setAliveTasks]);
useEffect(() => {
if (isInitialized === false) {
void listener()
.then(() => {
console.log("registered events listener");
isInitialized = true;
})
.catch((error) => {
console.error("failed to register events listener", error);
isInitialized = true;
});
}
}, [listener]);
};

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,
});

View File

@@ -8,9 +8,11 @@ import { z } from "zod";
import { configSchema } from "../config/config"; import { configSchema } from "../config/config";
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import type { SerializableAliveTask } from "../service/claude-code/types"; import type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { getEventBus } from "../service/events/EventBus"; import { getEventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher"; import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEventResponse } from "../service/events/sseEventResponse"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import { writeTypeSafeSSE } from "../service/events/typeSafeSSE";
import { getFileCompletion } from "../service/file-completion/getFileCompletion"; import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getBranches } from "../service/git/getBranches"; import { getBranches } from "../service/git/getBranches";
import { getCommits } from "../service/git/getCommits"; import { getCommits } from "../service/git/getCommits";
@@ -25,6 +27,14 @@ import { configMiddleware } from "./middleware/config.middleware";
export const routes = (app: HonoAppType) => { export const routes = (app: HonoAppType) => {
const taskController = new ClaudeCodeTaskController(); const taskController = new ClaudeCodeTaskController();
const fileWatcher = getFileWatcher();
const eventBus = getEventBus();
fileWatcher.startWatching();
setInterval(() => {
eventBus.emit("heartbeat", {});
}, 10 * 1000);
return ( return (
app app
@@ -375,108 +385,52 @@ export const routes = (app: HonoAppType) => {
}, },
) )
.get("/events/state_changes", async (c) => { .get("/sse", async (c) => {
return streamSSE( return streamSSE(
c, c,
async (stream) => { async (rawStream) => {
const fileWatcher = getFileWatcher(); const stream = writeTypeSafeSSE(rawStream);
const eventBus = getEventBus();
let isConnected = true; const onSessionListChanged = (
event: InternalEventDeclaration["sessionListChanged"],
// ハートビート設定 ) => {
const heartbeat = setInterval(() => { stream.writeSSE("sessionListChanged", {
if (isConnected) { projectId: event.projectId,
eventBus.emit("heartbeat", { });
type: "heartbeat",
});
}
}, 30 * 1000);
// connection handling
const abortController = new AbortController();
let connectionResolve: ((value: undefined) => void) | undefined;
const connectionPromise = new Promise<undefined>((resolve) => {
connectionResolve = resolve;
});
const onConnectionClosed = () => {
isConnected = false;
connectionResolve?.(undefined);
abortController.abort();
clearInterval(heartbeat);
}; };
// 接続終了時のクリーンアップ const onSessionChanged = (
stream.onAbort(() => { event: InternalEventDeclaration["sessionChanged"],
console.log("SSE connection aborted"); ) => {
onConnectionClosed(); stream.writeSSE("sessionChanged", {
}); projectId: event.projectId,
sessionId: event.sessionId,
// イベントリスナーを登録
console.log("Registering SSE event listeners");
eventBus.on("connected", async (event) => {
if (!isConnected) {
return;
}
await stream.writeSSE(sseEventResponse(event)).catch(() => {
onConnectionClosed();
}); });
}); };
eventBus.on("heartbeat", async (event) => { const onTaskChanged = (
if (!isConnected) { event: InternalEventDeclaration["taskChanged"],
return; ) => {
} stream.writeSSE("taskChanged", {
await stream.writeSSE(sseEventResponse(event)).catch(() => { aliveTasks: event.aliveTasks,
onConnectionClosed();
}); });
};
eventBus.on("sessionListChanged", onSessionListChanged);
eventBus.on("sessionChanged", onSessionChanged);
eventBus.on("taskChanged", onTaskChanged);
const { connectionPromise } = adaptInternalEventToSSE(rawStream, {
cleanUp: () => {
eventBus.off("sessionListChanged", onSessionListChanged);
eventBus.off("sessionChanged", onSessionChanged);
eventBus.off("taskChanged", onTaskChanged);
},
}); });
eventBus.on("project_changed", async (event) => {
if (!isConnected) {
return;
}
await stream.writeSSE(sseEventResponse(event)).catch(() => {
console.warn("Failed to write SSE event");
onConnectionClosed();
});
});
eventBus.on("session_changed", async (event) => {
if (!isConnected) {
return;
}
await stream.writeSSE(sseEventResponse(event)).catch(() => {
onConnectionClosed();
});
});
eventBus.on("task_changed", async (event) => {
if (!isConnected) {
return;
}
await stream.writeSSE(sseEventResponse(event)).catch(() => {
onConnectionClosed();
});
});
// 初期接続確認メッセージ
eventBus.emit("connected", {
type: "connected",
message: "SSE connection established",
});
fileWatcher.startWatching();
await connectionPromise; await connectionPromise;
}, },
async (err, stream) => { async (err) => {
console.error("Streaming error:", err); console.error("Streaming error:", err);
await stream.write("エラーが発生しました。");
}, },
); );
}) })

View File

@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
import { query } from "@anthropic-ai/claude-code"; import { query } from "@anthropic-ai/claude-code";
import prexit from "prexit"; import prexit from "prexit";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { type EventBus, getEventBus } from "../events/EventBus"; import { getEventBus, type IEventBus } from "../events/EventBus";
import { createMessageGenerator } from "./createMessageGenerator"; import { createMessageGenerator } from "./createMessageGenerator";
import type { import type {
AliveClaudeCodeTask, AliveClaudeCodeTask,
@@ -14,7 +14,7 @@ import type {
export class ClaudeCodeTaskController { export class ClaudeCodeTaskController {
private pathToClaudeCodeExecutable: string; private pathToClaudeCodeExecutable: string;
private tasks: ClaudeCodeTask[] = []; private tasks: ClaudeCodeTask[] = [];
private eventBus: EventBus; private eventBus: IEventBus;
constructor() { constructor() {
this.pathToClaudeCodeExecutable = execSync("which claude", {}) this.pathToClaudeCodeExecutable = execSync("which claude", {})
@@ -239,9 +239,8 @@ export class ClaudeCodeTaskController {
Object.assign(target, task); Object.assign(target, task);
this.eventBus.emit("task_changed", { this.eventBus.emit("taskChanged", {
type: "task_changed", aliveTasks: this.aliveTasks,
data: this.aliveTasks,
}); });
} }
} }

View File

@@ -1,45 +1,47 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:stream";
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
import type { BaseSSEEvent, SSEEvent } from "./types"; class EventBus {
private emitter: EventEmitter;
export class EventBus { constructor() {
private previousId = 0; this.emitter = new EventEmitter();
private eventEmitter = new EventEmitter(); }
public emit< public emit<EventName extends keyof InternalEventDeclaration>(
T extends SSEEvent["type"], event: EventName,
E = SSEEvent extends infer I ? (I extends { type: T } ? I : never) : never, data: InternalEventDeclaration[EventName],
>(type: T, event: Omit<E, "id" | "timestamp">): void { ): void {
const base: BaseSSEEvent = { this.emitter.emit(event, {
id: String(this.previousId++), ...data,
timestamp: new Date().toISOString(),
};
this.eventEmitter.emit(type, {
...event,
...base,
}); });
} }
public on( public on<EventName extends keyof InternalEventDeclaration>(
event: SSEEvent["type"], event: EventName,
listener: (event: SSEEvent) => void, listener: (
data: InternalEventDeclaration[EventName],
) => void | Promise<void>,
): void { ): void {
this.eventEmitter.on(event, listener); this.emitter.on(event, listener);
} }
public off( public off<EventName extends keyof InternalEventDeclaration>(
event: SSEEvent["type"], event: EventName,
listener: (event: SSEEvent) => void, listener: (
data: InternalEventDeclaration[EventName],
) => void | Promise<void>,
): void { ): void {
this.eventEmitter.off(event, listener); this.emitter.off(event, listener);
} }
} }
// Singleton // singleton
let eventBusInstance: EventBus | null = null; let eventBus: EventBus | null = null;
export const getEventBus = (): EventBus => { export const getEventBus = () => {
eventBusInstance ??= new EventBus(); eventBus ??= new EventBus();
return eventBusInstance; return eventBus;
}; };
export type IEventBus = ReturnType<typeof getEventBus>;

View File

@@ -0,0 +1,19 @@
import type { AliveClaudeCodeTask } from "../claude-code/types";
export type InternalEventDeclaration = {
// biome-ignore lint/complexity/noBannedTypes: correct type
heartbeat: {};
sessionListChanged: {
projectId: string;
};
sessionChanged: {
projectId: string;
sessionId: string;
};
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
};
};

View File

@@ -0,0 +1,61 @@
import type { SSEStreamingApi } from "hono/streaming";
import { getEventBus } from "./EventBus";
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
import { writeTypeSafeSSE } from "./typeSafeSSE";
export const adaptInternalEventToSSE = (
rawStream: SSEStreamingApi,
options?: {
timeout?: number;
cleanUp?: () => void | Promise<void>;
},
) => {
const { timeout = 60 * 1000, cleanUp } = options ?? {};
console.log("SSE connection started");
const eventBus = getEventBus();
const stream = writeTypeSafeSSE(rawStream);
const abortController = new AbortController();
let connectionResolve: (() => void) | undefined;
const connectionPromise = new Promise<void>((resolve) => {
connectionResolve = resolve;
});
const closeConnection = () => {
console.log("SSE connection closed");
connectionResolve?.();
abortController.abort();
eventBus.off("heartbeat", heartbeat);
cleanUp?.();
};
rawStream.onAbort(() => {
console.log("SSE connection aborted");
closeConnection();
});
// Event Listeners
const heartbeat = (event: InternalEventDeclaration["heartbeat"]) => {
stream.writeSSE("heartbeat", {
...event,
});
};
eventBus.on("heartbeat", heartbeat);
stream.writeSSE("connect", {
timestamp: new Date().toISOString(),
});
setTimeout(() => {
closeConnection();
}, timeout);
return {
connectionPromise,
} as const;
};

View File

@@ -1,7 +1,7 @@
import { type FSWatcher, watch } from "node:fs"; import { type FSWatcher, watch } from "node:fs";
import z from "zod"; import z from "zod";
import { claudeProjectPath } from "../paths"; import { claudeProjectPath } from "../paths";
import { type EventBus, getEventBus } from "./EventBus"; import { getEventBus, type IEventBus } from "./EventBus";
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/; const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
const fileRegExpGroupSchema = z.object({ const fileRegExpGroupSchema = z.object({
@@ -10,15 +10,19 @@ const fileRegExpGroupSchema = z.object({
}); });
export class FileWatcherService { export class FileWatcherService {
private isWatching = false;
private watcher: FSWatcher | null = null; private watcher: FSWatcher | null = null;
private projectWatchers: Map<string, FSWatcher> = new Map(); private projectWatchers: Map<string, FSWatcher> = new Map();
private eventBus: EventBus; private eventBus: IEventBus;
constructor() { constructor() {
this.eventBus = getEventBus(); this.eventBus = getEventBus();
} }
public startWatching(): void { public startWatching(): void {
if (this.isWatching) return;
this.isWatching = true;
try { try {
console.log("Starting file watcher on:", claudeProjectPath); console.log("Starting file watcher on:", claudeProjectPath);
// メインプロジェクトディレクトリを監視 // メインプロジェクトディレクトリを監視
@@ -36,22 +40,20 @@ export class FileWatcherService {
const { projectId, sessionId } = groups.data; const { projectId, sessionId } = groups.data;
this.eventBus.emit("project_changed", { if (eventType === "change") {
type: "project_changed", // セッションファイルの中身が変更されている
data: { this.eventBus.emit("sessionChanged", {
fileEventType: eventType,
projectId,
},
});
this.eventBus.emit("session_changed", {
type: "session_changed",
data: {
projectId, projectId,
sessionId, sessionId,
fileEventType: eventType, });
}, } else if (eventType === "rename") {
}); // セッションファイルの追加/削除
this.eventBus.emit("sessionListChanged", {
projectId,
});
} else {
eventType satisfies never;
}
}, },
); );
console.log("File watcher initialization completed"); console.log("File watcher initialization completed");

View File

@@ -1,9 +0,0 @@
import type { SSEEvent } from "./types";
export const sseEventResponse = (event: SSEEvent) => {
return {
data: JSON.stringify(event),
event: event.type,
id: event.id,
};
};

View File

@@ -0,0 +1,21 @@
import type { SSEStreamingApi } from "hono/streaming";
import { ulid } from "ulid";
import type { SSEEventDeclaration } from "../../../types/sse";
export const writeTypeSafeSSE = (stream: SSEStreamingApi) => ({
writeSSE: async <EventName extends keyof SSEEventDeclaration>(
event: EventName,
data: SSEEventDeclaration[EventName],
): Promise<void> => {
const id = ulid();
await stream.writeSSE({
event: event,
id: id,
data: JSON.stringify({
kind: event,
timestamp: new Date().toISOString(),
...data,
}),
});
},
});

View File

@@ -1,53 +0,0 @@
import type { WatchEventType } from "node:fs";
import type { SerializableAliveTask } from "../claude-code/types";
export type WatcherEvent =
| {
eventType: "project_changed";
data: ProjectChangedData;
}
| {
eventType: "session_changed";
data: SessionChangedData;
};
export type BaseSSEEvent = {
id: string;
timestamp: string;
};
export type SSEEvent = BaseSSEEvent &
(
| {
type: "connected";
message: string;
timestamp: string;
}
| {
type: "heartbeat";
timestamp: string;
}
| {
type: "project_changed";
data: ProjectChangedData;
}
| {
type: "session_changed";
data: SessionChangedData;
}
| {
type: "task_changed";
data: SerializableAliveTask[];
}
);
export interface ProjectChangedData {
projectId: string;
fileEventType: WatchEventType;
}
export interface SessionChangedData {
projectId: string;
sessionId: string;
fileEventType: WatchEventType;
}

View File

@@ -160,8 +160,6 @@ async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
cwd, cwd,
); );
console.log("debug statusResult stdout", statusResult);
if (!statusResult.success) { if (!statusResult.success) {
return statusResult; return statusResult;
} }
@@ -334,7 +332,6 @@ export const getDiff = async (
// Include untracked files when comparing to working directory // Include untracked files when comparing to working directory
if (toRef === undefined) { if (toRef === undefined) {
const untrackedResult = await getUntrackedFiles(cwd); const untrackedResult = await getUntrackedFiles(cwd);
console.log("debug untrackedResult", untrackedResult);
if (untrackedResult.success) { if (untrackedResult.success) {
for (const untrackedFile of untrackedResult.data) { for (const untrackedFile of untrackedResult.data) {
const untrackedDiff = await createUntrackedFileDiff( const untrackedDiff = await createUntrackedFileDiff(

31
src/types/sse.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { AliveClaudeCodeTask } from "../server/service/claude-code/types";
export type SSEEventDeclaration = {
// biome-ignore lint/complexity/noBannedTypes: correct type
connect: {};
// biome-ignore lint/complexity/noBannedTypes: correct type
heartbeat: {};
sessionListChanged: {
projectId: string;
};
sessionChanged: {
projectId: string;
sessionId: string;
};
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
};
};
export type SSEEventMap = {
[K in keyof SSEEventDeclaration]: SSEEventDeclaration[K] & {
kind: K;
timestamp: string;
};
};
export type SSEEvent = SSEEventMap[keyof SSEEventDeclaration];