130 lines
3.2 KiB
TypeScript
130 lines
3.2 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import Redis from 'ioredis';
|
|
import { env } from '../../../config/env.js';
|
|
|
|
/**
|
|
* SSE Chat Stream Routes
|
|
*
|
|
* Provides real-time updates for video chat using Server-Sent Events (SSE)
|
|
* and Redis pub/sub for broadcasting.
|
|
*/
|
|
|
|
// Redis client for pub/sub
|
|
let redisPubClient: Redis | null = null;
|
|
|
|
function getRedisClient(): Redis {
|
|
if (!redisPubClient) {
|
|
redisPubClient = new Redis(env.REDIS_URL);
|
|
}
|
|
return redisPubClient;
|
|
}
|
|
|
|
export async function chatStreamRoutes(fastify: FastifyInstance) {
|
|
/**
|
|
* GET /public/:id/stream
|
|
* SSE endpoint for real-time chat updates
|
|
*/
|
|
fastify.get(
|
|
'/public/:id/chat-stream',
|
|
async (
|
|
request: FastifyRequest<{ Params: { id: string } }>,
|
|
reply: FastifyReply
|
|
) => {
|
|
const videoId = parseInt(request.params.id, 10);
|
|
|
|
if (isNaN(videoId)) {
|
|
return reply.code(400).send({ message: 'Invalid video ID' });
|
|
}
|
|
|
|
// Set SSE headers
|
|
reply.raw.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
});
|
|
|
|
// Create dedicated Redis subscriber for this connection
|
|
const subscriber = new Redis(env.REDIS_URL);
|
|
|
|
const channel = `video:${videoId}:chat`;
|
|
|
|
// Set up message handler
|
|
subscriber.on('message', (receivedChannel, message) => {
|
|
if (receivedChannel === channel) {
|
|
// Send message to client via SSE
|
|
reply.raw.write(`data: ${message}\n\n`);
|
|
}
|
|
});
|
|
|
|
// Subscribe to channel
|
|
await subscriber.subscribe(channel);
|
|
|
|
// Send initial connection confirmation
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', videoId })}\n\n`);
|
|
|
|
// Keep-alive ping every 30 seconds
|
|
const pingInterval = setInterval(() => {
|
|
reply.raw.write(': ping\n\n');
|
|
}, 30000);
|
|
|
|
// Cleanup on disconnect
|
|
request.raw.on('close', async () => {
|
|
clearInterval(pingInterval);
|
|
await subscriber.unsubscribe(channel);
|
|
subscriber.disconnect();
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Broadcast a new comment to all connected clients for a video
|
|
*/
|
|
export function broadcastCommentToVideo(
|
|
videoId: number,
|
|
comment: any
|
|
): void {
|
|
try {
|
|
const redis = getRedisClient();
|
|
const channel = `video:${videoId}:chat`;
|
|
const message = JSON.stringify({
|
|
type: 'new_comment',
|
|
comment,
|
|
});
|
|
redis.publish(channel, message);
|
|
} catch (error) {
|
|
console.error('Failed to broadcast comment:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Broadcast a new reaction to all connected clients for a video
|
|
*/
|
|
export function broadcastReactionToVideo(
|
|
videoId: number,
|
|
reaction: any
|
|
): void {
|
|
try {
|
|
const redis = getRedisClient();
|
|
const channel = `video:${videoId}:chat`;
|
|
const message = JSON.stringify({
|
|
type: 'new_reaction',
|
|
reaction,
|
|
});
|
|
redis.publish(channel, message);
|
|
} catch (error) {
|
|
console.error('Failed to broadcast reaction:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup Redis connections on shutdown
|
|
*/
|
|
export function closeRedisConnections(): void {
|
|
if (redisPubClient) {
|
|
redisPubClient.disconnect();
|
|
redisPubClient = null;
|
|
}
|
|
}
|