Commit 890016a8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add relay server with connection logging

Full relay server code with comprehensive logging for debugging
connection drops, packet routing, and keepalive terminations.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9270af9c
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
#!/bin/bash
# Deploy IStalk Relay Server to CapRover
# Usage: ./deploy.sh
set -e
APP_NAME="istalk-relay"
SERVER="18.192.166.221"
SSH_KEY="/Users/mahmoudaglan/NewMigration/newServer.pem"
SSH_USER="ubuntu"
SSH_CMD="ssh -i $SSH_KEY -o StrictHostKeyChecking=no $SSH_USER@$SERVER"
CAPROVER_URL="https://captain.caprover.al-arcade.com"
CAPROVER_PASSWORD="Alarcade123#"
echo "=== Deploying IStalk Relay Server to CapRover ==="
# Step 1: Get CapRover auth token
echo "[1/6] Getting auth token..."
TOKEN=$($SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/login \
-H 'Content-Type: application/json' \
-H 'x-namespace: captain' \
-d '{\"password\":\"$CAPROVER_PASSWORD\"}'" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")
echo "Token acquired."
# Step 2: Create the app (ignore if exists)
echo "[2/6] Creating app '$APP_NAME'..."
$SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/user/apps/appDefinitions/register \
-H 'Content-Type: application/json' \
-H 'x-namespace: captain' \
-H 'x-captain-auth: $TOKEN' \
-d '{\"appName\":\"$APP_NAME\",\"hasPersistentData\":false}'" || true
# Step 3: Enable WebSocket support + set container port
echo "[3/6] Enabling WebSocket support..."
$SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/user/apps/appDefinitions/update \
-H 'Content-Type: application/json' \
-H 'x-namespace: captain' \
-H 'x-captain-auth: $TOKEN' \
-d '{\"appName\":\"$APP_NAME\",\"websocketSupport\":true,\"containerHttpPort\":3000}'"
# Step 4: Deploy via tar upload
echo "[4/6] Packaging and deploying..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
tar -czf /tmp/istalk-relay.tar.gz --exclude='node_modules' -C "$SCRIPT_DIR" .
scp -i $SSH_KEY -o StrictHostKeyChecking=no /tmp/istalk-relay.tar.gz $SSH_USER@$SERVER:/tmp/
$SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/user/apps/appData/$APP_NAME \
-H 'x-namespace: captain' \
-H 'x-captain-auth: $TOKEN' \
-F 'sourceFile=@/tmp/istalk-relay.tar.gz'"
# Step 5: Enable HTTPS
echo "[5/6] Enabling HTTPS..."
sleep 10
$SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/user/apps/appDefinitions/enablebasedomainssl \
-H 'Content-Type: application/json' \
-H 'x-namespace: captain' \
-H 'x-captain-auth: $TOKEN' \
-d '{\"appName\":\"$APP_NAME\"}'"
# Step 6: Force HTTPS
echo "[6/6] Forcing HTTPS..."
$SSH_CMD "curl -sk -X POST $CAPROVER_URL/api/v2/user/apps/appDefinitions/update \
-H 'Content-Type: application/json' \
-H 'x-namespace: captain' \
-H 'x-captain-auth: $TOKEN' \
-d '{\"appName\":\"$APP_NAME\",\"forceSsl\":true}'"
echo ""
echo "=== Deployment complete! ==="
echo "URL: https://$APP_NAME.caprover.al-arcade.com"
echo "Health: https://$APP_NAME.caprover.al-arcade.com/health"
echo "WebSocket: wss://$APP_NAME.caprover.al-arcade.com"
{
"name": "istalk-relay",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "istalk-relay",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
{
"name": "istalk-relay",
"version": "1.0.0",
"description": "IStalk relay + lobby server",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}
const { WebSocketServer, WebSocket } = require('ws');
const http = require('http');
const { RoomManager } = require('./rooms');
const { RelayHandler } = require('./relay');
const PORT = process.env.PORT || 3000;
function log(tag, msg) {
const ts = new Date().toISOString().slice(11, 23);
console.log(`[${ts}] [${tag}] ${msg}`);
}
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
rooms: roomManager.getRoomCount(),
connections: wss.clients.size,
uptime: process.uptime()
}));
return;
}
if (req.url === '/stats') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(roomManager.getStats()));
return;
}
res.writeHead(404);
res.end('Not found');
});
const wss = new WebSocketServer({ server });
const roomManager = new RoomManager();
const relayHandler = new RelayHandler(roomManager);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.playerId = null;
ws.roomCode = null;
ws.playerName = null;
ws.isRelaying = false;
log('CONN', `New WebSocket connection. Total clients: ${wss.clients.size}`);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data, isBinary) => {
try {
if (isBinary) {
relayHandler.handlePacket(ws, data);
return;
}
const msg = JSON.parse(data.toString());
log('MSG', `player=${ws.playerId} room=${ws.roomCode} type=${msg.type}`);
handleMessage(ws, msg);
} catch (e) {
log('ERR', `Bad message from player=${ws.playerId}: ${e.message}`);
sendError(ws, 'Invalid message format');
}
});
ws.on('close', (code, reason) => {
log('CLOSE', `player=${ws.playerId} room=${ws.roomCode} name=${ws.playerName} code=${code} reason=${reason || 'none'}`);
if (ws.roomCode) {
const room = roomManager.getRoom(ws.roomCode);
if (room) {
room.removePlayer(ws.playerId);
if (room.isEmpty()) {
log('ROOM', `Room ${ws.roomCode} deleted (empty)`);
roomManager.deleteRoom(ws.roomCode);
} else {
log('ROOM', `Room ${ws.roomCode} now has ${room.getPlayerCount()} players`);
broadcastLobbyState(room);
}
}
}
});
ws.on('error', (err) => {
log('WS_ERR', `player=${ws.playerId} room=${ws.roomCode}: ${err.message}`);
});
});
// Ping/pong keepalive
setInterval(() => {
let terminated = 0;
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
log('PING', `Terminating dead connection: player=${ws.playerId} room=${ws.roomCode}`);
ws.terminate();
terminated++;
return;
}
ws.isAlive = false;
ws.ping();
});
if (terminated > 0) log('PING', `Terminated ${terminated} dead connections`);
}, 30000);
// Room cleanup (stale rooms older than 30 min)
setInterval(() => {
roomManager.cleanupStale(30 * 60 * 1000);
}, 60000);
function handleMessage(ws, msg) {
switch (msg.type) {
case 'create_room': handleCreateRoom(ws, msg); break;
case 'join_room': handleJoinRoom(ws, msg); break;
case 'set_ready': handleSetReady(ws, msg); break;
case 'swap_role': handleSwapRole(ws, msg); break;
case 'start_game': handleStartGame(ws, msg); break;
case 'set_name': handleSetName(ws, msg); break;
case 'leave_room': handleLeaveRoom(ws); break;
case 'return_to_lobby': handleReturnToLobby(ws); break;
default: sendError(ws, `Unknown message type: ${msg.type}`);
}
}
function handleCreateRoom(ws, msg) {
if (ws.roomCode) {
sendError(ws, 'Already in a room');
return;
}
const room = roomManager.createRoom();
const playerId = room.addPlayer(ws, msg.name || 'Host', true);
ws.playerId = playerId;
ws.roomCode = room.code;
ws.playerName = msg.name || 'Host';
send(ws, {
type: 'room_created',
roomCode: room.code,
playerId: playerId
});
broadcastLobbyState(room);
}
function handleJoinRoom(ws, msg) {
if (ws.roomCode) {
sendError(ws, 'Already in a room');
return;
}
const code = (msg.roomCode || '').toUpperCase().trim();
const room = roomManager.getRoom(code);
if (!room) {
sendError(ws, 'Room not found');
return;
}
if (room.state === 'playing') {
sendError(ws, 'Game already in progress');
return;
}
if (room.getPlayerCount() >= 4) {
sendError(ws, 'Room is full');
return;
}
const playerId = room.addPlayer(ws, msg.name || `Player ${room.getPlayerCount()}`, false);
ws.playerId = playerId;
ws.roomCode = code;
ws.playerName = msg.name || `Player ${room.getPlayerCount()}`;
send(ws, {
type: 'room_joined',
roomCode: code,
playerId: playerId
});
broadcastLobbyState(room);
}
function handleSetReady(ws, msg) {
const room = roomManager.getRoom(ws.roomCode);
if (!room || room.state !== 'lobby') return;
room.setReady(ws.playerId, msg.ready);
broadcastLobbyState(room);
}
function handleSetName(ws, msg) {
const room = roomManager.getRoom(ws.roomCode);
if (!room) return;
room.setName(ws.playerId, msg.name);
ws.playerName = msg.name;
broadcastLobbyState(room);
}
function handleSwapRole(ws, msg) {
const room = roomManager.getRoom(ws.roomCode);
if (!room || room.state !== 'lobby') return;
room.swapRole(ws.playerId);
broadcastLobbyState(room);
}
function handleStartGame(ws, msg) {
const room = roomManager.getRoom(ws.roomCode);
if (!room || room.state !== 'lobby') return;
const player = room.getPlayer(ws.playerId);
if (!player || !player.isHost) {
sendError(ws, 'Only host can start');
return;
}
if (!room.canStart()) {
sendError(ws, 'Not all players ready or need at least 2 players');
return;
}
room.state = 'starting';
broadcastLobbyState(room);
// 3 second countdown
let countdown = 3;
const interval = setInterval(() => {
countdown--;
broadcast(room, { type: 'countdown', seconds: countdown });
if (countdown <= 0) {
clearInterval(interval);
room.state = 'playing';
// Tell everyone to connect to relay mode
broadcast(room, {
type: 'game_start',
players: room.getPlayersData(),
hostId: room.getHostId()
});
}
}, 1000);
}
function handleLeaveRoom(ws) {
const room = roomManager.getRoom(ws.roomCode);
if (!room) return;
room.removePlayer(ws.playerId);
ws.roomCode = null;
ws.playerId = null;
send(ws, { type: 'left_room' });
if (room.isEmpty()) {
roomManager.deleteRoom(room.code);
} else {
broadcastLobbyState(room);
}
}
function handleReturnToLobby(ws) {
const room = roomManager.getRoom(ws.roomCode);
if (!room) return;
const player = room.getPlayer(ws.playerId);
if (!player || !player.isHost) return;
room.state = 'lobby';
room.resetReady();
room.players.forEach(p => { p.ws.isRelaying = false; });
broadcastLobbyState(room);
}
function broadcastLobbyState(room) {
broadcast(room, {
type: 'lobby_state',
state: room.state,
roomCode: room.code,
players: room.getPlayersData()
});
}
function broadcast(room, msg) {
const data = JSON.stringify(msg);
room.players.forEach(p => {
if (p.ws.readyState === WebSocket.OPEN) {
p.ws.send(data);
}
});
}
function send(ws, msg) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function sendError(ws, error) {
send(ws, { type: 'error', error });
}
server.listen(PORT, () => {
console.log(`IStalk Relay Server running on port ${PORT}`);
});
const { WebSocket } = require('ws');
function log(tag, msg) {
const ts = new Date().toISOString().slice(11, 23);
console.log(`[${ts}] [${tag}] ${msg}`);
}
class RelayHandler {
constructor(roomManager) {
this.roomManager = roomManager;
this._packetCount = 0;
}
handlePacket(ws, data) {
if (!ws.roomCode) {
log('RELAY', `Binary from player=${ws.playerId} but no room!`);
return;
}
const room = this.roomManager.getRoom(ws.roomCode);
if (!room || room.state !== 'playing') {
log('RELAY', `Binary from player=${ws.playerId} but room state=${room?.state || 'null'}`);
return;
}
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (buffer.length < 2) return;
const target = buffer[0];
const payload = buffer.slice(1);
// Prepend sender ID so receiver knows who sent it
const relayedPacket = Buffer.alloc(payload.length + 2);
relayedPacket.writeUInt16LE(ws.playerId, 0);
payload.copy(relayedPacket, 2);
this._packetCount++;
if (this._packetCount <= 20 || this._packetCount % 100 === 0) {
log('RELAY', `#${this._packetCount} from=${ws.playerId} target=0x${target.toString(16)} len=${buffer.length} room=${ws.roomCode}`);
}
switch (target) {
case 0x00: // Broadcast to all except sender
this.broadcastExcept(room, ws.playerId, relayedPacket);
break;
case 0x01: // To host only
this.sendToHost(room, relayedPacket);
break;
case 0xFF: // From host to all clients
this.broadcastExcept(room, ws.playerId, relayedPacket);
break;
default: // To specific player
this.sendToPlayer(room, target, relayedPacket);
break;
}
}
broadcastExcept(room, excludeId, data) {
room.players.forEach(p => {
if (p.id !== excludeId && p.ws.readyState === WebSocket.OPEN) {
p.ws.send(data);
}
});
}
sendToHost(room, data) {
const hostId = room.getHostId();
if (hostId) {
this.sendToPlayer(room, hostId, data);
}
}
sendToPlayer(room, playerId, data) {
const ws = room.getPlayerWs(playerId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
module.exports = { RelayHandler };
const ROLE_EYE = 'Eye';
const ROLE_RUNNER = 'Runner';
class Room {
constructor(code) {
this.code = code;
this.state = 'lobby'; // lobby | starting | playing
this.players = [];
this.createdAt = Date.now();
this.nextPlayerId = 1;
}
addPlayer(ws, name, isHost) {
const id = this.nextPlayerId++;
const role = this.hasEye() ? ROLE_RUNNER : ROLE_EYE;
this.players.push({
id,
ws,
name,
role,
isReady: false,
isHost
});
return id;
}
removePlayer(playerId) {
const idx = this.players.findIndex(p => p.id === playerId);
if (idx < 0) return;
const removed = this.players[idx];
this.players.splice(idx, 1);
// If host left, promote next player
if (removed.isHost && this.players.length > 0) {
this.players[0].isHost = true;
}
// If Eye left, reassign
if (removed.role === ROLE_EYE && this.players.length > 0) {
this.players[0].role = ROLE_EYE;
}
}
getPlayer(playerId) {
return this.players.find(p => p.id === playerId);
}
hasEye() {
return this.players.some(p => p.role === ROLE_EYE);
}
setReady(playerId, ready) {
const p = this.getPlayer(playerId);
if (p) p.isReady = ready;
}
setName(playerId, name) {
const p = this.getPlayer(playerId);
if (p) p.name = name.substring(0, 20);
}
swapRole(playerId) {
const requester = this.getPlayer(playerId);
if (!requester) return;
if (requester.role === ROLE_RUNNER) {
// Take Eye from current Eye
const currentEye = this.players.find(p => p.role === ROLE_EYE);
if (currentEye) {
currentEye.role = ROLE_RUNNER;
currentEye.isReady = false;
}
requester.role = ROLE_EYE;
requester.isReady = false;
} else if (this.players.length > 1) {
// Give Eye to first Runner
requester.role = ROLE_RUNNER;
requester.isReady = false;
const nextRunner = this.players.find(p => p.id !== playerId && p.role === ROLE_RUNNER);
if (nextRunner) {
nextRunner.role = ROLE_EYE;
nextRunner.isReady = false;
}
}
}
canStart() {
if (this.players.length < 2) return false;
if (!this.hasEye()) return false;
return this.players.every(p => p.isReady);
}
resetReady() {
this.players.forEach(p => { p.isReady = false; });
}
isEmpty() {
return this.players.length === 0;
}
getPlayerCount() {
return this.players.length;
}
getHostId() {
const host = this.players.find(p => p.isHost);
return host ? host.id : null;
}
getPlayersData() {
return this.players.map(p => ({
id: p.id,
name: p.name,
role: p.role,
isReady: p.isReady,
isHost: p.isHost
}));
}
getPlayerWs(playerId) {
const p = this.getPlayer(playerId);
return p ? p.ws : null;
}
}
class RoomManager {
constructor() {
this.rooms = new Map();
}
createRoom() {
let code;
do {
code = this.generateCode();
} while (this.rooms.has(code));
const room = new Room(code);
this.rooms.set(code, room);
return room;
}
getRoom(code) {
return this.rooms.get(code) || null;
}
deleteRoom(code) {
this.rooms.delete(code);
}
getRoomCount() {
return this.rooms.size;
}
getStats() {
const rooms = [];
this.rooms.forEach((room, code) => {
rooms.push({
code,
state: room.state,
players: room.getPlayerCount(),
createdAt: room.createdAt
});
});
return { totalRooms: this.rooms.size, rooms };
}
cleanupStale(maxAge) {
const now = Date.now();
const toDelete = [];
this.rooms.forEach((room, code) => {
if (now - room.createdAt > maxAge && room.isEmpty()) {
toDelete.push(code);
}
});
toDelete.forEach(code => this.rooms.delete(code));
}
generateCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
}
module.exports = { Room, RoomManager };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment