mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-18 13:04:20 +01:00
feat: move configuration localStorage to server side
This commit is contained in:
7
src/server/config/config.ts
Normal file
7
src/server/config/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const configSchema = z.object({
|
||||
hideNoUserMessageSession: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Config } from "../config/config";
|
||||
|
||||
// biome-ignore lint/complexity/noBannedTypes: add after
|
||||
export type HonoContext = {};
|
||||
export type HonoContext = {
|
||||
Variables: {
|
||||
config: Config;
|
||||
};
|
||||
};
|
||||
|
||||
export const honoApp = new Hono<HonoContext>().basePath("/api");
|
||||
|
||||
|
||||
31
src/server/hono/middleware/config.middleware.ts
Normal file
31
src/server/hono/middleware/config.middleware.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { configSchema } from "../../config/config";
|
||||
import type { HonoContext } from "../app";
|
||||
|
||||
export const configMiddleware = createMiddleware<HonoContext>(
|
||||
async (c, next) => {
|
||||
const cookie = getCookie(c, "ccv-config");
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return configSchema.parse(JSON.parse(cookie ?? "{}"));
|
||||
} catch {
|
||||
return configSchema.parse({});
|
||||
}
|
||||
})();
|
||||
|
||||
if (cookie === undefined) {
|
||||
setCookie(
|
||||
c,
|
||||
"ccv-config",
|
||||
JSON.stringify({
|
||||
hideNoUserMessageSession: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
c.set("config", parsed);
|
||||
|
||||
await next();
|
||||
},
|
||||
);
|
||||
@@ -2,8 +2,10 @@ import { readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { z } from "zod";
|
||||
import { configSchema } from "../config/config";
|
||||
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
|
||||
import { getFileWatcher } from "../service/events/fileWatcher";
|
||||
import { sseEvent } from "../service/events/sseEvent";
|
||||
@@ -13,240 +15,271 @@ import { getProjects } from "../service/project/getProjects";
|
||||
import { getSession } from "../service/session/getSession";
|
||||
import { getSessions } from "../service/session/getSessions";
|
||||
import type { HonoAppType } from "./app";
|
||||
import { configMiddleware } from "./middleware/config.middleware";
|
||||
|
||||
export const routes = (app: HonoAppType) => {
|
||||
const taskController = new ClaudeCodeTaskController();
|
||||
|
||||
return app
|
||||
.get("/projects", async (c) => {
|
||||
const { projects } = await getProjects();
|
||||
return c.json({ projects });
|
||||
})
|
||||
return (
|
||||
app
|
||||
// middleware
|
||||
.use(configMiddleware)
|
||||
|
||||
.get("/projects/:projectId", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
// routes
|
||||
.get("/config", async (c) => {
|
||||
return c.json({
|
||||
config: c.get("config"),
|
||||
});
|
||||
})
|
||||
|
||||
const [{ project }, { sessions }] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getSessions(projectId),
|
||||
] as const);
|
||||
.put("/config", zValidator("json", configSchema), async (c) => {
|
||||
const { ...config } = c.req.valid("json");
|
||||
|
||||
return c.json({ project, sessions });
|
||||
})
|
||||
setCookie(c, "ccv-config", JSON.stringify(config));
|
||||
|
||||
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { session } = await getSession(projectId, sessionId);
|
||||
return c.json({ session });
|
||||
})
|
||||
return c.json({
|
||||
config,
|
||||
});
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/claude-commands", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await getProject(projectId);
|
||||
.get("/projects", async (c) => {
|
||||
const { projects } = await getProjects();
|
||||
return c.json({ projects });
|
||||
})
|
||||
|
||||
const [globalCommands, projectCommands] = await Promise.allSettled([
|
||||
readdir(resolve(homedir(), ".claude", "commands"), {
|
||||
withFileTypes: true,
|
||||
}).then((dirents) =>
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".md"))
|
||||
.map((d) => d.name.replace(/\.md$/, "")),
|
||||
),
|
||||
project.meta.projectPath !== null
|
||||
? readdir(resolve(project.meta.projectPath, ".claude", "commands"), {
|
||||
withFileTypes: true,
|
||||
}).then((dirents) =>
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".md"))
|
||||
.map((d) => d.name.replace(/\.md$/, "")),
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
globalCommands:
|
||||
globalCommands.status === "fulfilled" ? globalCommands.value : [],
|
||||
projectCommands:
|
||||
projectCommands.status === "fulfilled" ? projectCommands.value : [],
|
||||
});
|
||||
})
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/new-session",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
.get("/projects/:projectId", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { message } = c.req.valid("json");
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
const [{ project }, { sessions }] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getSessions(projectId).then(({ sessions }) => ({
|
||||
sessions: sessions.filter((session) => {
|
||||
if (c.get("config").hideNoUserMessageSession) {
|
||||
return session.meta.firstCommand !== null;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
})),
|
||||
] as const);
|
||||
|
||||
const task = await taskController.createTask({
|
||||
projectId,
|
||||
cwd: project.meta.projectPath,
|
||||
message,
|
||||
});
|
||||
return c.json({ project, sessions });
|
||||
})
|
||||
|
||||
const { nextSessionId, userMessageId } = await taskController.startTask(
|
||||
task.id,
|
||||
);
|
||||
return c.json({ taskId: task.id, nextSessionId, userMessageId });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/sessions/:sessionId/resume",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
resumeMessage: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { resumeMessage } = c.req.valid("json");
|
||||
const { session } = await getSession(projectId, sessionId);
|
||||
return c.json({ session });
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/claude-commands", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
const [globalCommands, projectCommands] = await Promise.allSettled([
|
||||
readdir(resolve(homedir(), ".claude", "commands"), {
|
||||
withFileTypes: true,
|
||||
}).then((dirents) =>
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".md"))
|
||||
.map((d) => d.name.replace(/\.md$/, "")),
|
||||
),
|
||||
project.meta.projectPath !== null
|
||||
? readdir(
|
||||
resolve(project.meta.projectPath, ".claude", "commands"),
|
||||
{
|
||||
withFileTypes: true,
|
||||
},
|
||||
).then((dirents) =>
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".md"))
|
||||
.map((d) => d.name.replace(/\.md$/, "")),
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
|
||||
const task = await taskController.createTask({
|
||||
projectId,
|
||||
sessionId,
|
||||
cwd: project.meta.projectPath,
|
||||
message: resumeMessage,
|
||||
return c.json({
|
||||
globalCommands:
|
||||
globalCommands.status === "fulfilled" ? globalCommands.value : [],
|
||||
projectCommands:
|
||||
projectCommands.status === "fulfilled" ? projectCommands.value : [],
|
||||
});
|
||||
})
|
||||
|
||||
const { nextSessionId, userMessageId } = await taskController.startTask(
|
||||
task.id,
|
||||
);
|
||||
return c.json({ taskId: task.id, nextSessionId, userMessageId });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/projects/:projectId/new-session",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { message } = c.req.valid("json");
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
.get("/tasks/running", async (c) => {
|
||||
return c.json({ runningTasks: taskController.runningTasks });
|
||||
})
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
.get("/events/state_changes", async (c) => {
|
||||
return streamSSE(
|
||||
c,
|
||||
async (stream) => {
|
||||
const fileWatcher = getFileWatcher();
|
||||
let isConnected = true;
|
||||
let eventId = 0;
|
||||
const task = await taskController.createTask({
|
||||
projectId,
|
||||
cwd: project.meta.projectPath,
|
||||
message,
|
||||
});
|
||||
|
||||
// ハートビート設定
|
||||
const heartbeat = setInterval(() => {
|
||||
if (isConnected) {
|
||||
stream
|
||||
const { nextSessionId, userMessageId } =
|
||||
await taskController.startTask(task.id);
|
||||
return c.json({ taskId: task.id, nextSessionId, userMessageId });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/sessions/:sessionId/resume",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
resumeMessage: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { resumeMessage } = c.req.valid("json");
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
const task = await taskController.createTask({
|
||||
projectId,
|
||||
sessionId,
|
||||
cwd: project.meta.projectPath,
|
||||
message: resumeMessage,
|
||||
});
|
||||
|
||||
const { nextSessionId, userMessageId } =
|
||||
await taskController.startTask(task.id);
|
||||
return c.json({ taskId: task.id, nextSessionId, userMessageId });
|
||||
},
|
||||
)
|
||||
|
||||
.get("/tasks/running", async (c) => {
|
||||
return c.json({ runningTasks: taskController.runningTasks });
|
||||
})
|
||||
|
||||
.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<undefined>((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: "heartbeat",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: event.eventType,
|
||||
...event.data,
|
||||
}),
|
||||
event: "heartbeat",
|
||||
event: event.eventType,
|
||||
id: String(eventId++),
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Failed to write SSE event");
|
||||
isConnected = false;
|
||||
onConnectionClosed();
|
||||
});
|
||||
}
|
||||
}, 30 * 1000);
|
||||
});
|
||||
fileWatcher.on("session_changed", async (event: WatcherEvent) => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// connection handling
|
||||
const abortController = new AbortController();
|
||||
let connectionResolve: ((value: undefined) => void) | undefined;
|
||||
const connectionPromise = new Promise<undefined>((resolve) => {
|
||||
connectionResolve = resolve;
|
||||
});
|
||||
await stream
|
||||
.writeSSE({
|
||||
data: sseEvent({
|
||||
...event.data,
|
||||
type: event.eventType,
|
||||
}),
|
||||
event: event.eventType,
|
||||
id: String(eventId++),
|
||||
})
|
||||
.catch(() => {
|
||||
onConnectionClosed();
|
||||
});
|
||||
});
|
||||
|
||||
const onConnectionClosed = () => {
|
||||
isConnected = false;
|
||||
connectionResolve?.(undefined);
|
||||
abortController.abort();
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
// 初期接続確認メッセージ
|
||||
await stream.writeSSE({
|
||||
data: sseEvent({
|
||||
type: "connected",
|
||||
message: "SSE connection established",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
event: "connected",
|
||||
id: String(eventId++),
|
||||
});
|
||||
|
||||
// 接続終了時のクリーンアップ
|
||||
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("エラーが発生しました。");
|
||||
},
|
||||
);
|
||||
});
|
||||
await connectionPromise;
|
||||
},
|
||||
async (err, stream) => {
|
||||
console.error("Streaming error:", err);
|
||||
await stream.write("エラーが発生しました。");
|
||||
},
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export type RouteType = ReturnType<typeof routes>;
|
||||
|
||||
Reference in New Issue
Block a user