118 lines
3.0 KiB
TypeScript
118 lines
3.0 KiB
TypeScript
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<string, Set<(data: string) => 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
);
|
|
}
|