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 (
-
+
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;
+}