import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import jwt from 'jsonwebtoken'; import { env } from '../../../config/env'; import { UserRole } from '@prisma/client'; /** * Chat Notifications SSE Routes * * Provides per-user SSE streams for real-time chat reply notifications. * Since EventSource can't send Authorization headers, JWT is passed as query param. */ interface TokenPayload { id: string; email: string; role: UserRole; } // In-memory subscriber map: userId → Set of SSE writers const subscribers = new Map void>>(); /** * Notify a user of a chat reply via SSE */ export function notifyUser(userId: string, notification: { type: 'chat_reply'; videoId: number; videoTitle: string; commentId: number; commenterName: string; contentPreview: string; }): void { const writers = subscribers.get(userId); if (!writers || writers.size === 0) return; const data = JSON.stringify(notification); for (const write of writers) { try { write(data); } catch { // Writer disconnected, will be cleaned up } } } export async function chatNotificationsRoutes(fastify: FastifyInstance) { /** * GET /notifications/stream?token=JWT * Per-user SSE stream for chat reply notifications */ fastify.get( '/notifications/stream', async ( request: FastifyRequest<{ Querystring: { token?: string } }>, reply: FastifyReply ) => { const token = request.query.token; if (!token) { return reply.code(401).send({ message: 'Authentication token required' }); } // Verify JWT let payload: TokenPayload; try { payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; } catch { return reply.code(401).send({ message: 'Invalid or expired token' }); } const userId = payload.id; // Set SSE headers reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); // Writer function for this connection const writer = (data: string) => { reply.raw.write(`data: ${data}\n\n`); }; // Register subscriber if (!subscribers.has(userId)) { subscribers.set(userId, new Set()); } subscribers.get(userId)!.add(writer); // Send connection confirmation reply.raw.write(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`); // Keep-alive ping every 30 seconds const pingInterval = setInterval(() => { try { reply.raw.write(': ping\n\n'); } catch { // Connection closed } }, 30000); // Cleanup on disconnect request.raw.on('close', () => { clearInterval(pingInterval); const writers = subscribers.get(userId); if (writers) { writers.delete(writer); if (writers.size === 0) { subscribers.delete(userId); } } }); } ); }