Skip to main content

Custom Server Setup

Watch N Chill uses a custom Node.js HTTP server that integrates Next.js and Socket.IO on the same port.

Server Initialization

The main server file (server.ts) creates an HTTP server that handles both Next.js pages and Socket.IO connections:
server.ts:42-79
app.prepare().then(() => {
  const { windowMs } = getRateLimitConfig();
  const retryAfterSec = Math.ceil(windowMs / 1000);

  const httpServer = createServer(async (req, res) => {
    try {
      if (req.url && (req.url === '/health')) {
        res.statusCode = 200;
        res.setHeader('content-type', 'text/plain; charset=utf-8');
        res.setHeader('cache-control', 'no-store');
        res.end("heart beating");
        return;
      }

      if (!dev) {
        const { allowed, remaining } = await checkRateLimit(req);
        res.setHeader('X-RateLimit-Limit', String(getRateLimitConfig().maxRequests));
        res.setHeader('X-RateLimit-Remaining', String(remaining));
        if (!allowed) {
          res.statusCode = 429;
          res.setHeader('Retry-After', String(retryAfterSec));
          res.setHeader('content-type', 'text/plain; charset=utf-8');
          res.end('Too Many Requests');
          return;
        }
      }

      const parsedUrl = parse(req.url!, true);
      await handle(req, res, parsedUrl);
    } catch (err) {
      console.error('Error occurred handling', req.url, err);
      res.statusCode = 500;
      res.end('internal server error');
    }
  });

  // Initialize Socket.IO with the TypeScript implementation
  initSocketIO(httpServer);

  httpServer.listen(port, () => {
    console.log(`> Ready on http://${hostname}:${port}`);
    console.log(`> Socket.IO server running on path: /api/socket/io`);
  });
});
The server runs on a single port (default: 3000) and routes requests to either Next.js or Socket.IO based on the path.

Socket.IO Integration

Initialization

Socket.IO is initialized with CORS configuration and connection handling:
socket/index.ts:16-57
export function initSocketIO(httpServer: HTTPServer): IOServer {
  if (io) {
    return io;
  }
  io = new IOServer(httpServer, {
    cors: {
      origin:
        process.env.NODE_ENV === 'production'
          ? process.env.ALLOWED_ORIGINS?.split(',') || []
          : ['http://localhost:3000'],
      methods: ['GET', 'POST'],
      credentials: true,
    },
    path: '/api/socket/io',
  });

  io.on('connection', async (socket) => {
    const forwarded = socket.handshake.headers['x-forwarded-for'];
    const clientIp =
      (typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : forwarded?.[0]?.trim()) ??
      socket.handshake.address ??
      socket.conn.remoteAddress ??
      'unknown';
    socket.data.clientIp = clientIp;

    const { allowed } = await checkSocketConnectionAllowed(clientIp);
    if (!allowed) {
      socket.disconnect(true);
      return;
    }

    console.log('User connected:', socket.id);

    registerRoomHandlers(socket, io!);
    registerVideoHandlers(socket, io!);
    registerChatHandlers(socket, io!);

    socket.on('disconnect', () => handleDisconnect(socket));
  });

  return io;
}

Event Handler Architecture

Socket.IO events are organized into three handler modules:

Room Handler

  • create-room
  • join-room
  • leave-room
  • promote-host

Video Handler

  • set-video
  • play-video
  • pause-video
  • seek-video
  • sync-check

Chat Handler

  • send-message
  • typing-start
  • typing-stop

Event Validation with Zod

All incoming Socket.IO events are validated using Zod schemas before processing:
socket/utils.ts:6-25
export function validateData<T>(
  schema: z.ZodSchema<T>,
  data: unknown,
  socket: Socket<SocketEvents, SocketEvents, object, SocketData>
): T | null {
  try {
    return schema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation error:', error.issues);
      socket.emit('room-error', {
        error: `Invalid data: ${error.issues.map(issue => issue.message).join(', ')}`,
      });
    } else {
      console.error('Unexpected validation error:', error);
      socket.emit('room-error', { error: 'Invalid data provided' });
    }
    return null;
  }
}

Example: Room Creation

socket/room-handler.ts:11-57
socket.on('create-room', async data => {
  try {
    const validatedData = validateData(CreateRoomDataSchema, data, socket);
    if (!validatedData) return;

    const { hostName } = validatedData;
    const roomId = generateRoomId();
    const userId = uuidv4();

    const user: User = {
      id: userId,
      name: hostName,
      isHost: true,
      joinedAt: new Date(),
    };

    const room: Room = {
      id: roomId,
      hostId: userId,
      hostName: hostName,
      hostToken: uuidv4(),
      videoType: null,
      videoState: {
        isPlaying: false,
        currentTime: 0,
        duration: 0,
        lastUpdateTime: Date.now(),
      },
      users: [user],
      createdAt: new Date(),
    };

    await redisService.rooms.createRoom(room);

    socket.data.userId = userId;
    socket.data.userName = hostName;
    socket.data.roomId = roomId;

    await socket.join(roomId);

    socket.emit('room-created', { roomId, room, hostToken: room.hostToken });
    socket.emit('room-joined', { room, user });
    console.log(`Room ${roomId} created by ${hostName}`);
  } catch (error) {
    console.error('Error creating room:', error);
    socket.emit('room-error', { error: 'Failed to create room' });
  }
});
types/schemas.ts:63-65
export const CreateRoomDataSchema = z.object({
  hostName: UserNameSchema,
});

Redis Repositories

Redis is abstracted through repository classes that handle data persistence:

RoomRepository

Manages room state with 24-hour TTL:
backend/redis/room-handler.ts:14-41
async createRoom(room: Room): Promise<void> {
  await redis.setex(`room:${room.id}`, 86400, JSON.stringify(room)); // 24 hours TTL
  await redis.sadd('active-rooms', room.id);
}

async getRoom(roomId: string): Promise<Room | null> {
  const roomData = await redis.get(`room:${roomId}`);
  if (!roomData) return null;

  const room = JSON.parse(roomData) as Room;
  // Convert date strings back to Date objects
  room.createdAt = new Date(room.createdAt);
  room.users = room.users.map(user => ({
    ...user,
    joinedAt: new Date(user.joinedAt),
  }));

  return room;
}

async updateRoom(roomId: string, room: Room): Promise<void> {
  await redis.setex(`room:${roomId}`, 86400, JSON.stringify(room));
}

async deleteRoom(roomId: string): Promise<void> {
  await redis.del(`room:${roomId}`);
  await redis.srem('active-rooms', roomId);
}
backend/redis/room-handler.ts:79-101
async updateVideoState(roomId: string, videoState: VideoState): Promise<void> {
  const room = await this.getRoom(roomId);
  if (!room) throw new Error('Room not found');

  room.videoState = videoState;
  await this.updateRoom(roomId, room);
}

async setVideoUrl(roomId: string, videoUrl: string, videoType: 'youtube'): Promise<void> {
  const room = await this.getRoom(roomId);
  if (!room) throw new Error('Room not found');

  room.videoUrl = videoUrl;
  room.videoType = videoType;
  // Reset video state when new video is set
  room.videoState = {
    isPlaying: false,
    currentTime: 0,
    duration: 0,
    lastUpdateTime: Date.now(),
  };

  await this.updateRoom(roomId, room);
}
backend/redis/room-handler.ts:47-76
async addUserToRoom(roomId: string, user: User): Promise<void> {
  const room = await this.getRoom(roomId);
  if (!room) throw new Error('Room not found');

  // Remove user if already exists (rejoin case)
  room.users = room.users.filter(u => u.id !== user.id);
  room.users.push(user);

  await this.updateRoom(roomId, room);
}

async removeUserFromRoom(roomId: string, userId: string): Promise<void> {
  const room = await this.getRoom(roomId);
  if (!room) return;

  room.users = room.users.filter(u => u.id !== userId);

  // If no users left, delete the room
  if (room.users.length === 0) {
    await this.deleteRoom(roomId);
  } else {
    // If host left, assign new host
    if (room.hostId === userId && room.users.length > 0) {
      const newHost = room.users[0];
      room.hostId = newHost.id;
      room.hostName = newHost.name;
      newHost.isHost = true;
    }
    await this.updateRoom(roomId, room);
  }
}

ChatRepository

Stores last 20 messages using Redis lists:
backend/redis/chat-handler.ts:14-30
async addChatMessage(roomId: string, message: ChatMessage): Promise<void> {
  const key = `chat:${roomId}`;
  await redis.lpush(key, JSON.stringify(message));
  await redis.ltrim(key, 0, 19); // Keep only last 20 messages
  await redis.expire(key, 86400); // 24 hours TTL
}

async getChatMessages(roomId: string, limit: number = 20): Promise<ChatMessage[]> {
  const messages = await redis.lrange(`chat:${roomId}`, 0, limit - 1);
  return messages
    .map(msg => {
      const parsed = JSON.parse(msg) as ChatMessage;
      parsed.timestamp = new Date(parsed.timestamp);
      return parsed;
    })
    .reverse(); // Reverse to get chronological order
}
Messages are stored in reverse chronological order (newest first) using lpush, then reversed when retrieved to display chronologically.

Rate Limiting

The application implements two layers of rate limiting:

HTTP Rate Limiting

backend/rate-limit.ts:9-67
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10); // 1 minute
const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '360', 10);

// Lua script: increment key, set expiry on first request in window, return current count
const LUA_SCRIPT = `
  local current = redis.call('INCR', KEYS[1])
  if current == 1 then
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
  end
  return current
`;

async function checkRedisRateLimit(ip: string): Promise<{ allowed: boolean; remaining: number }> {
  const key = `${RATE_LIMIT_KEY_PREFIX}${ip}`;
  const windowMs = RATE_LIMIT_WINDOW_MS.toString();
  const current = (await redis.eval(LUA_SCRIPT, 1, key, windowMs)) as number;
  const allowed = current <= RATE_LIMIT_MAX_REQUESTS;
  const remaining = Math.max(0, RATE_LIMIT_MAX_REQUESTS - current);
  return { allowed, remaining };
}

export async function checkRateLimit(req: IncomingMessage): Promise<{ allowed: boolean; remaining: number }> {
  const ip = getClientIp(req);
  try {
    return await checkRedisRateLimit(ip);
  } catch {
    return await checkMemoryRateLimit(ip); // Fallback to in-memory
  }
}

Socket Connection Limiting

backend/rate-limit.ts:86-111
const SOCKET_CONN_MAX_PER_IP = parseInt(process.env.RATE_LIMIT_SOCKET_MAX_PER_IP || '10', 10);

export async function checkSocketConnectionAllowed(ip: string): Promise<{ allowed: boolean }> {
  const key = `${SOCKET_KEY_PREFIX}${ip}`;
  try {
    const current = await redis.incr(key);
    if (current === 1) await redis.expire(key, 3600); // 1h TTL
    return { allowed: current <= SOCKET_CONN_MAX_PER_IP };
  } catch {
    return { allowed: true }; // allow on Redis failure to avoid blocking all connections
  }
}

export async function decrementSocketConnection(ip: string): Promise<void> {
  const key = `${SOCKET_KEY_PREFIX}${ip}`;
  try {
    await redis.decr(key);
  } catch {
    // ignore
  }
}
Default Configuration:
  • 360 requests per minute per IP
  • Enforced in production only
  • Headers: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After
Response:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 360
X-RateLimit-Remaining: 0
Retry-After: 60

Video Control Events

Host-only actions are enforced server-side:
socket/video-handler.ts:45-82
socket.on('play-video', async data => {
  try {
    const validatedData = validateData(VideoControlDataSchema, data, socket);
    if (!validatedData) return;

    const { roomId, currentTime } = validatedData;
    const room = await redisService.rooms.getRoom(roomId);
    if (!room) {
      socket.emit('room-error', { error: 'Room not found' });
      return;
    }

    const currentUser = room.users.find(u => u.id === socket.data.userId);
    if (!currentUser?.isHost) {
      socket.emit('error', { error: 'Only hosts can control the video' });
      return;
    }

    const videoState = {
      isPlaying: true,
      currentTime,
      duration: room.videoState.duration,
      lastUpdateTime: Date.now(),
    };

    await redisService.rooms.updateVideoState(roomId, videoState);

    socket.to(roomId).emit('video-played', {
      currentTime,
      timestamp: videoState.lastUpdateTime,
    });

    console.log(`Video played in room ${roomId} at ${currentTime}s`);
  } catch (error) {
    console.error('Error playing video:', error);
    socket.emit('error', { error: 'Failed to play video' });
  }
});
All video control actions (play, pause, seek) require the user to be a host. This is checked server-side to prevent unauthorized control attempts.

Host Sync Mechanism

Hosts send periodic sync checks every 5 seconds:
socket/video-handler.ts:164-196
socket.on('sync-check', async data => {
  try {
    const validatedData = validateData(SyncCheckDataSchema, data, socket);
    if (!validatedData) return;

    const { roomId, currentTime, isPlaying, timestamp } = validatedData;

    if (!socket.data.userId) {
      socket.emit('error', { error: 'Not authenticated' });
      return;
    }

    const room = await redisService.rooms.getRoom(roomId);
    if (!room) {
      socket.emit('room-error', { error: 'Room not found' });
      return;
    }

    const currentUser = room.users.find(u => u.id === socket.data.userId);
    if (!currentUser?.isHost) {
      socket.emit('error', { error: 'Only hosts can send sync checks' });
      return;
    }

    // Broadcast sync update to all other users
    socket.to(roomId).emit('sync-update', { currentTime, isPlaying, timestamp });

    console.log(`Sync check sent in room ${roomId}: ${currentTime.toFixed(2)}s, playing: ${isPlaying}`);
  } catch (error) {
    console.error('Error sending sync check:', error);
    socket.emit('error', { error: 'Failed to send sync check' });
  }
});

Next Steps

Frontend Architecture

Learn how the React frontend consumes Socket.IO events

Real-Time Sync

Understand the video synchronization algorithm

Build docs developers (and LLMs) love