Commit 1ebad2bc authored by Mahmoud Aglan's avatar Mahmoud Aglan

init relay server

parents
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;
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;
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());
handleMessage(ws, msg);
} catch (e) {
sendError(ws, 'Invalid message format');
}
});
ws.on('close', () => {
if (ws.roomCode) {
const room = roomManager.getRoom(ws.roomCode);
if (room) {
room.removePlayer(ws.playerId);
if (room.isEmpty()) {
roomManager.deleteRoom(ws.roomCode);
} else {
broadcastLobbyState(room);
}
}
}
});
});
// Ping/pong keepalive
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping();
});
}, 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');
class RelayHandler {
constructor(roomManager) {
this.roomManager = roomManager;
}
handlePacket(ws, data) {
if (!ws.roomCode) return;
const room = this.roomManager.getRoom(ws.roomCode);
if (!room || room.state !== 'playing') return;
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
// Packet format:
// First byte = target:
// 0x00 = broadcast to all except sender
// 0x01 = to host only
// 0xFF = to all clients (from host)
// 0x02-0xFE = to specific player ID
// Remaining bytes = FishNet payload
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);
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