mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-29 18:34:26 +01:00
perf: refactor sse handleing
This commit is contained in:
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import { query } from "@anthropic-ai/claude-code";
|
||||
import prexit from "prexit";
|
||||
import { ulid } from "ulid";
|
||||
import { type EventBus, getEventBus } from "../events/EventBus";
|
||||
import { getEventBus, type IEventBus } from "../events/EventBus";
|
||||
import { createMessageGenerator } from "./createMessageGenerator";
|
||||
import type {
|
||||
AliveClaudeCodeTask,
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
export class ClaudeCodeTaskController {
|
||||
private pathToClaudeCodeExecutable: string;
|
||||
private tasks: ClaudeCodeTask[] = [];
|
||||
private eventBus: EventBus;
|
||||
private eventBus: IEventBus;
|
||||
|
||||
constructor() {
|
||||
this.pathToClaudeCodeExecutable = execSync("which claude", {})
|
||||
@@ -239,9 +239,8 @@ export class ClaudeCodeTaskController {
|
||||
|
||||
Object.assign(target, task);
|
||||
|
||||
this.eventBus.emit("task_changed", {
|
||||
type: "task_changed",
|
||||
data: this.aliveTasks,
|
||||
this.eventBus.emit("taskChanged", {
|
||||
aliveTasks: this.aliveTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
private previousId = 0;
|
||||
private eventEmitter = new EventEmitter();
|
||||
constructor() {
|
||||
this.emitter = new EventEmitter();
|
||||
}
|
||||
|
||||
public emit<
|
||||
T extends SSEEvent["type"],
|
||||
E = SSEEvent extends infer I ? (I extends { type: T } ? I : never) : never,
|
||||
>(type: T, event: Omit<E, "id" | "timestamp">): void {
|
||||
const base: BaseSSEEvent = {
|
||||
id: String(this.previousId++),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.eventEmitter.emit(type, {
|
||||
...event,
|
||||
...base,
|
||||
public emit<EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
data: InternalEventDeclaration[EventName],
|
||||
): void {
|
||||
this.emitter.emit(event, {
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
public on(
|
||||
event: SSEEvent["type"],
|
||||
listener: (event: SSEEvent) => void,
|
||||
public on<EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: (
|
||||
data: InternalEventDeclaration[EventName],
|
||||
) => void | Promise<void>,
|
||||
): void {
|
||||
this.eventEmitter.on(event, listener);
|
||||
this.emitter.on(event, listener);
|
||||
}
|
||||
|
||||
public off(
|
||||
event: SSEEvent["type"],
|
||||
listener: (event: SSEEvent) => void,
|
||||
public off<EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: (
|
||||
data: InternalEventDeclaration[EventName],
|
||||
) => void | Promise<void>,
|
||||
): void {
|
||||
this.eventEmitter.off(event, listener);
|
||||
this.emitter.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let eventBusInstance: EventBus | null = null;
|
||||
// singleton
|
||||
let eventBus: EventBus | null = null;
|
||||
|
||||
export const getEventBus = (): EventBus => {
|
||||
eventBusInstance ??= new EventBus();
|
||||
return eventBusInstance;
|
||||
export const getEventBus = () => {
|
||||
eventBus ??= new EventBus();
|
||||
return eventBus;
|
||||
};
|
||||
|
||||
export type IEventBus = ReturnType<typeof getEventBus>;
|
||||
|
||||
19
src/server/service/events/InternalEventDeclaration.ts
Normal file
19
src/server/service/events/InternalEventDeclaration.ts
Normal 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[];
|
||||
};
|
||||
};
|
||||
61
src/server/service/events/adaptInternalEventToSSE.ts
Normal file
61
src/server/service/events/adaptInternalEventToSSE.ts
Normal 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;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
import z from "zod";
|
||||
import { claudeProjectPath } from "../paths";
|
||||
import { type EventBus, getEventBus } from "./EventBus";
|
||||
import { getEventBus, type IEventBus } from "./EventBus";
|
||||
|
||||
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
|
||||
const fileRegExpGroupSchema = z.object({
|
||||
@@ -10,15 +10,19 @@ const fileRegExpGroupSchema = z.object({
|
||||
});
|
||||
|
||||
export class FileWatcherService {
|
||||
private isWatching = false;
|
||||
private watcher: FSWatcher | null = null;
|
||||
private projectWatchers: Map<string, FSWatcher> = new Map();
|
||||
private eventBus: EventBus;
|
||||
private eventBus: IEventBus;
|
||||
|
||||
constructor() {
|
||||
this.eventBus = getEventBus();
|
||||
}
|
||||
|
||||
public startWatching(): void {
|
||||
if (this.isWatching) return;
|
||||
this.isWatching = true;
|
||||
|
||||
try {
|
||||
console.log("Starting file watcher on:", claudeProjectPath);
|
||||
// メインプロジェクトディレクトリを監視
|
||||
@@ -36,22 +40,20 @@ export class FileWatcherService {
|
||||
|
||||
const { projectId, sessionId } = groups.data;
|
||||
|
||||
this.eventBus.emit("project_changed", {
|
||||
type: "project_changed",
|
||||
data: {
|
||||
fileEventType: eventType,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
this.eventBus.emit("session_changed", {
|
||||
type: "session_changed",
|
||||
data: {
|
||||
if (eventType === "change") {
|
||||
// セッションファイルの中身が変更されている
|
||||
this.eventBus.emit("sessionChanged", {
|
||||
projectId,
|
||||
sessionId,
|
||||
fileEventType: eventType,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (eventType === "rename") {
|
||||
// セッションファイルの追加/削除
|
||||
this.eventBus.emit("sessionListChanged", {
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
eventType satisfies never;
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log("File watcher initialization completed");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
21
src/server/service/events/typeSafeSSE.ts
Normal file
21
src/server/service/events/typeSafeSSE.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -160,8 +160,6 @@ async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
|
||||
cwd,
|
||||
);
|
||||
|
||||
console.log("debug statusResult stdout", statusResult);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult;
|
||||
}
|
||||
@@ -334,7 +332,6 @@ export const getDiff = async (
|
||||
// Include untracked files when comparing to working directory
|
||||
if (toRef === undefined) {
|
||||
const untrackedResult = await getUntrackedFiles(cwd);
|
||||
console.log("debug untrackedResult", untrackedResult);
|
||||
if (untrackedResult.success) {
|
||||
for (const untrackedFile of untrackedResult.data) {
|
||||
const untrackedDiff = await createUntrackedFileDiff(
|
||||
|
||||
Reference in New Issue
Block a user