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)
This commit is contained in:
@ -11,6 +11,9 @@ import { BoardService } from '@/services/BoardService';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { NoteService } from '@/services/NoteService';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { ChatService } from '@/services/ChatService';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { getSseHub } from '@/lib/sse/hub';
|
||||
|
||||
/**
|
||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||
@ -54,6 +57,17 @@ export function createNoteService(): NoteService {
|
||||
);
|
||||
}
|
||||
|
||||
export function createChatService(): ChatService {
|
||||
const db = getDb();
|
||||
return new ChatService(
|
||||
new ChatRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new UserRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
getSseHub()
|
||||
);
|
||||
}
|
||||
|
||||
export function createUserRepository(): UserRepository {
|
||||
return new UserRepository(getDb());
|
||||
}
|
||||
|
||||
65
lib/sse/hub.ts
Normal file
65
lib/sse/hub.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
@ -277,16 +277,25 @@ export type NotificationEvent =
|
||||
projectMemberIds: number[];
|
||||
});
|
||||
|
||||
/** SSE配信イベント(M8でSseHubが扱う)。M5ではbroadcasterの型として使用 */
|
||||
/** SSE配信イベント(M8でSseHubが扱う)。 */
|
||||
export type SseEvent =
|
||||
| { type: 'notification.created'; data: { projectId: number | null } }
|
||||
| { type: 'chat.message.created'; data: { projectId: number } }
|
||||
| { type: 'chat.message.updated'; data: { projectId: number } }
|
||||
| { type: 'chat.message.deleted'; data: { projectId: number; id: number } }
|
||||
| {
|
||||
type: 'chat.message.created';
|
||||
data: { projectId: number; message: ChatMessage };
|
||||
}
|
||||
| {
|
||||
type: 'chat.message.updated';
|
||||
data: { projectId: number; message: ChatMessage };
|
||||
}
|
||||
| {
|
||||
type: 'chat.message.deleted';
|
||||
data: { projectId: number; id: number };
|
||||
}
|
||||
| { type: 'todo.updated'; data: { projectId: number } }
|
||||
| { type: 'file.uploaded'; data: { projectId: number } }
|
||||
| { type: 'meeting.created'; data: { projectId: number } }
|
||||
| { type: 'note.updated'; data: { projectId: number } };
|
||||
| { type: 'note.updated'; data: { projectId: number } }
|
||||
| { type: 'notification.created'; data: { projectId: number | null } };
|
||||
|
||||
/** SSE配信を行うコンポーネントの抽象(M8のSseHubが実装) */
|
||||
export interface SseBroadcaster {
|
||||
|
||||
Reference in New Issue
Block a user