diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/package.json b/package.json index 9f988a8..46c61b2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "run-p 'dev:*'", "dev:next": "next dev -p 3400 --turbopack", - "build": "next build", + "build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/", "lint": "run-s 'lint:*'", "lint:biome-format": "biome format .", "lint:biome-lint": "biome check .", diff --git a/src/app/components/ServerEventsProvider.tsx b/src/app/components/ServerEventsProvider.tsx new file mode 100644 index 0000000..4181b64 --- /dev/null +++ b/src/app/components/ServerEventsProvider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useServerEvents } from "@/hooks/useServerEvents"; + +interface ServerEventsProviderProps { + children: React.ReactNode; +} + +export function ServerEventsProvider({ children }: ServerEventsProviderProps) { + useServerEvents(); + + return <>{children}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b7ef8d8..9198bce 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper"; import { RootErrorBoundary } from "./components/RootErrorBoundary"; +import { ServerEventsProvider } from "./components/ServerEventsProvider"; import "./globals.css"; @@ -16,8 +17,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Claude Code Viewer", + description: "Web Viewer for Claude Code history", }; export default function RootLayout({ @@ -26,12 +27,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index 964722b..3ed2f8a 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -12,6 +12,5 @@ export const useProject = (projectId: string) => { return await response.json(); }, refetchOnReconnect: true, - refetchInterval: 10 * 1000, }); }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts index 4929c4b..57234c6 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts @@ -3,7 +3,7 @@ import { honoClient } from "../../../../../../lib/api/client"; export const useSessionQuery = (projectId: string, sessionId: string) => { return useSuspenseQuery({ - queryKey: ["conversations", sessionId], + queryKey: ["sessions", sessionId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].sessions[ ":sessionId" diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 3717058..f3aff8e 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,6 +1,9 @@ import { HistoryIcon } from "lucide-react"; import { ProjectList } from "./components/ProjectList"; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + export default async function ProjectsPage() { return (
diff --git a/src/hooks/useServerEvents.ts b/src/hooks/useServerEvents.ts new file mode 100644 index 0000000..8d9cb74 --- /dev/null +++ b/src/hooks/useServerEvents.ts @@ -0,0 +1,105 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; +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.indexOf("}") + 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 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") { + console.log("invalidating projects"); + await queryClient.invalidateQueries({ queryKey: ["projects"] }); + } + + if (event.data.type === "session_changed") { + console.log("invalidating sessions"); + await queryClient.invalidateQueries({ queryKey: ["sessions"] }); + } + } + } + }, [queryClient]); + + 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]); +}; diff --git a/src/lib/sse/sseClient.ts b/src/lib/sse/sseClient.ts new file mode 100644 index 0000000..b59bf46 --- /dev/null +++ b/src/lib/sse/sseClient.ts @@ -0,0 +1,110 @@ +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(), + }; +} diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index fc501aa..e741a16 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,3 +1,7 @@ +import { streamSSE } from "hono/streaming"; +import { getFileWatcher } from "../service/events/fileWatcher"; +import { sseEvent } from "../service/events/sseEvent"; +import type { WatcherEvent } from "../service/events/types"; import { getProject } from "../service/project/getProject"; import { getProjects } from "../service/project/getProjects"; import { getSession } from "../service/session/getSession"; @@ -26,6 +30,118 @@ export const routes = (app: HonoAppType) => { const { projectId, sessionId } = c.req.param(); const { session } = await getSession(projectId, sessionId); return c.json({ session }); + }) + + .get("/events/state_changes", async (c) => { + return streamSSE( + c, + async (stream) => { + const fileWatcher = getFileWatcher(); + let isConnected = true; + let eventId = 0; + + // ハートビート設定 + const heartbeat = setInterval(() => { + if (isConnected) { + stream + .writeSSE({ + data: sseEvent({ + type: "heartbeat", + timestamp: new Date().toISOString(), + }), + event: "heartbeat", + id: String(eventId++), + }) + .catch(() => { + console.warn("Failed to write SSE event"); + isConnected = false; + onConnectionClosed(); + }); + } + }, 30 * 1000); + + // connection handling + const abortController = new AbortController(); + let connectionResolve: ((value: undefined) => void) | undefined; + const connectionPromise = new Promise((resolve) => { + connectionResolve = resolve; + }); + + const onConnectionClosed = () => { + isConnected = false; + connectionResolve?.(undefined); + abortController.abort(); + clearInterval(heartbeat); + }; + + // 接続終了時のクリーンアップ + stream.onAbort(() => { + console.log("SSE connection aborted"); + onConnectionClosed(); + }); + + // イベントリスナーを登録 + console.log("Registering SSE event listeners"); + fileWatcher.on("project_changed", async (event: WatcherEvent) => { + if (!isConnected) { + return; + } + + if (event.eventType !== "project_changed") { + return; + } + + await stream + .writeSSE({ + data: sseEvent({ + type: event.eventType, + ...event.data, + }), + event: event.eventType, + id: String(eventId++), + }) + .catch(() => { + console.warn("Failed to write SSE event"); + onConnectionClosed(); + }); + }); + fileWatcher.on("session_changed", async (event: WatcherEvent) => { + if (!isConnected) { + return; + } + + await stream + .writeSSE({ + data: sseEvent({ + ...event.data, + type: event.eventType, + }), + event: event.eventType, + id: String(eventId++), + }) + .catch(() => { + onConnectionClosed(); + }); + }); + + // 初期接続確認メッセージ + await stream.writeSSE({ + data: sseEvent({ + type: "connected", + message: "SSE connection established", + timestamp: new Date().toISOString(), + }), + event: "connected", + id: String(eventId++), + }); + + await connectionPromise; + }, + async (err, stream) => { + console.error("Streaming error:", err); + await stream.write("エラーが発生しました。"); + }, + ); }); }; diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts new file mode 100644 index 0000000..7d1c046 --- /dev/null +++ b/src/server/service/events/fileWatcher.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from "node:events"; +import { type FSWatcher, watch } from "node:fs"; +import z from "zod"; +import { claudeProjectPath } from "../paths"; +import type { WatcherEvent } from "./types"; + +const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; +const fileRegExpGroupSchema = z.object({ + projectId: z.string(), + sessionId: z.string(), +}); + +export class FileWatcherService extends EventEmitter { + private watcher: FSWatcher | null = null; + private projectWatchers: Map = new Map(); + + constructor() { + super(); + this.startWatching(); + } + + private startWatching(): void { + try { + console.log("Starting file watcher on:", claudeProjectPath); + // メインプロジェクトディレクトリを監視 + this.watcher = watch( + claudeProjectPath, + { persistent: false, recursive: true }, + (eventType, filename) => { + if (!filename) return; + + const groups = fileRegExpGroupSchema.safeParse( + filename.match(fileRegExp)?.groups, + ); + + if (!groups.success) return; + + const { projectId, sessionId } = groups.data; + + this.emit("project_changed", { + eventType: "project_changed", + data: { projectId, fileEventType: eventType }, + } satisfies WatcherEvent); + + this.emit("session_changed", { + eventType: "session_changed", + data: { + projectId, + sessionId, + fileEventType: eventType, + }, + } satisfies WatcherEvent); + }, + ); + console.log("File watcher initialization completed"); + } catch (error) { + console.error("Failed to start file watching:", error); + } + } + + public stop(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + + for (const [, watcher] of this.projectWatchers) { + watcher.close(); + } + this.projectWatchers.clear(); + } +} + +// シングルトンインスタンス +let watcherInstance: FileWatcherService | null = null; + +export const getFileWatcher = (): FileWatcherService => { + if (!watcherInstance) { + console.log("Creating new FileWatcher instance"); + watcherInstance = new FileWatcherService(); + } + return watcherInstance; +}; diff --git a/src/server/service/events/sseEvent.ts b/src/server/service/events/sseEvent.ts new file mode 100644 index 0000000..f915c21 --- /dev/null +++ b/src/server/service/events/sseEvent.ts @@ -0,0 +1,13 @@ +import type { BaseSSEEvent, SSEEvent } from "./types"; + +let eventId = 0; + +export const sseEvent = >( + data: D, +): string => { + return JSON.stringify({ + ...data, + id: String(eventId++), + timestamp: new Date().toISOString(), + } satisfies D & BaseSSEEvent); +}; diff --git a/src/server/service/events/types.ts b/src/server/service/events/types.ts new file mode 100644 index 0000000..b5583fc --- /dev/null +++ b/src/server/service/events/types.ts @@ -0,0 +1,50 @@ +import type { WatchEventType } from "node:fs"; + +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; + } + | { + id: string; + type: "project_changed"; + data: ProjectChangedData; + } + | { + id: string; + type: "session_changed"; + data: SessionChangedData; + } + ); + +export interface ProjectChangedData { + projectId: string; + fileEventType: WatchEventType; +} + +export interface SessionChangedData { + projectId: string; + sessionId: string; + fileEventType: WatchEventType; +}