- 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)
71 lines
1.9 KiB
TypeScript
71 lines
1.9 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
|
import { createProjectService } from '@/lib/api/services';
|
|
import { getSseHub } from '@/lib/sse/hub';
|
|
import { UnauthorizedError } from '@/lib/errors';
|
|
import { handleApiError } from '@/lib/api/handleError';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ projectId: string }> }
|
|
) {
|
|
const user = await getCurrentUser();
|
|
if (!user) return handleApiError(new UnauthorizedError());
|
|
const { projectId } = await params;
|
|
|
|
const projectService = createProjectService();
|
|
try {
|
|
projectService.getProject(user.id, Number(projectId));
|
|
} catch (error) {
|
|
return handleApiError(error);
|
|
}
|
|
|
|
const pid = Number(projectId);
|
|
const hub = getSseHub();
|
|
const encoder = new TextEncoder();
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const client = {
|
|
enqueue: (chunk: string) => controller.enqueue(encoder.encode(chunk)),
|
|
close: () => {
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// 既に閉じられている
|
|
}
|
|
},
|
|
};
|
|
hub.addClient(pid, client);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
try {
|
|
controller.enqueue(encoder.encode(': ping\n\n'));
|
|
} catch {
|
|
clearInterval(heartbeat);
|
|
}
|
|
}, 30_000);
|
|
|
|
request.signal.addEventListener('abort', () => {
|
|
clearInterval(heartbeat);
|
|
hub.removeClient(pid, client);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// 既に閉じられている
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
},
|
|
});
|
|
}
|