Files
Ken Yasue ddad5ae78c feat(m8): SSE hub + realtime chat with mentions, tests, e2e
- SseHub (project-scoped client mgmt + broadcast, implements SseBroadcaster)
  with SSE-formatted payload; singleton getSseHub
- ChatRepository (message CRUD, soft-delete, search, pagination, project
  isolation) + ChatService (membership permission, send/edit/delete,
  @email mention -> mention notification, SSE broadcast of created/updated/deleted)
- SSE stream endpoint (ReadableStream + heartbeat + abort cleanup, membership)
- Chat message API routes (history/send/edit/delete)
- ChatWindow client (EventSource auto-reconnect, history fetch, realtime
  append/update/delete), chat page, ProjectNav チャット link
- SseEvent type now carries ChatMessage for chat events
- Unit (SseHub, ChatRepository, ChatService) + integration chat-sse-broadcast
  + e2e chat-sse (two contexts realtime)
2026-06-25 01:45:34 +02:00

66 lines
1.9 KiB
TypeScript

import type { SseBroadcaster, SseEvent } from '@/lib/types';
/**
* SSEクライアントの抽象。ReadableStreamのcontrollerを包む。
*/
export interface SseClient {
enqueue(chunk: string): void;
close(): void;
}
/**
* プロジェクト単位のSSEクライアント管理・イベント配信を行うHub。
* 配信は当該プロジェクトのクライアントのみに行い、他プロジェクトへ漏らさない。
* モジュールシングルトン(getSseHub)でプロセス内共有する。
*/
export class SseHub implements SseBroadcaster {
private readonly clients = new Map<number, Set<SseClient>>();
addClient(projectId: number, client: SseClient): void {
let set = this.clients.get(projectId);
if (!set) {
set = new Set();
this.clients.set(projectId, set);
}
set.add(client);
}
removeClient(projectId: number, client: SseClient): void {
this.clients.get(projectId)?.delete(client);
}
broadcast(projectId: number, event: SseEvent): void {
const set = this.clients.get(projectId);
if (!set || set.size === 0) return;
const payload = `data: ${JSON.stringify(event)}\n\n`;
for (const client of set) {
try {
client.enqueue(payload);
} catch {
// 書き込み失敗(切断済み等)のクライアントは除去
set.delete(client);
}
}
}
/** テスト/監視用: プロジェクトの接続クライアント数 */
getClientCount(projectId: number): number {
return this.clients.get(projectId)?.size ?? 0;
}
}
let hubInstance: SseHub | null = null;
/** プロセス全体で共有するSseHubシングルトンを取得する */
export function getSseHub(): SseHub {
if (!hubInstance) {
hubInstance = new SseHub();
}
return hubInstance;
}
/** テスト用途: シングルトンをリセットする */
export function resetSseHub(): void {
hubInstance = null;
}