use node cluster to shard server

This commit is contained in:
Evan
2025-02-27 10:45:00 -08:00
parent 82c040c16a
commit 3494c54906
19 changed files with 1006 additions and 700 deletions
+1 -1
View File
@@ -16,6 +16,6 @@ COPY . .
# Build the client-side application
RUN npm run build-prod
# Expose the port the app runs on
EXPOSE 3000
EXPOSE 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015
# Define the command to run the app
CMD ["npm", "run", "start:server"]
+19 -3
View File
@@ -2,17 +2,33 @@ version: "3"
services:
game-server:
build: .
ports:
- "3000:3000"
expose:
- "3000"
- "3001"
- "3002"
- "3003"
- "3004"
- "3005"
- "3006"
- "3007"
- "3008"
- "3009"
- "3010"
- "3011"
- "3012"
- "3013"
- "3014"
- "3015"
environment:
- NODE_ENV=production
nginx:
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt
depends_on:
- game-server
+108
View File
@@ -0,0 +1,108 @@
resolver 127.0.0.11 valid=30s;
# Access log format with minimal information
log_format minimal '$remote_addr - [$time_local] "$request" $status';
map $uri $port {
~^/w0/ 3001;
~^/w1/ 3002;
~^/w2/ 3003;
~^/w3/ 3004;
~^/w4/ 3005;
~^/w5/ 3006;
~^/w6/ 3007;
~^/w7/ 3008;
~^/w8/ 3009;
~^/w9/ 3010;
~^/w10/ 3011;
~^/w11/ 3012;
~^/w12/ 3013;
~^/w13/ 3014;
~^/w14/ 3015;
default 3000;
}
# Don't strip the path for WebSocket connections
map $http_upgrade $strip_path {
default 1;
websocket 0;
}
map $uri $uri_path {
# Only strip path if not a WebSocket request
~^/w\d+(/.*)?$ $1;
default $uri;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
# Reduced logging
access_log /var/log/nginx/access.log minimal;
error_log /var/log/nginx/error.log error;
# Disable logging for common requests
location = /favicon.ico {
access_log off;
log_not_found off;
return 204;
}
# WebSocket timeout settings
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
# Special location block just for WebSocket connections
location ~* ^/w\d+$ {
set $ws_port 0;
if ($uri ~* ^/w0) {
set $ws_port 3001;
}
if ($uri ~* ^/w1) {
set $ws_port 3002;
}
if ($uri ~* ^/w2) {
set $ws_port 3003;
}
if ($uri ~* ^/w3) {
set $ws_port 3004;
}
if ($uri ~* ^/w4) {
set $ws_port 3005;
}
# Add more conditions for other worker paths
proxy_pass http://game-server:$ws_port;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
# Regular location for all other requests
location / {
set $upstream_endpoint game-server:$port;
proxy_pass http://$upstream_endpoint$uri_path;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
+55 -45
View File
@@ -1,11 +1,13 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Lobby } from "../core/Schemas";
import { GameConfig, GameInfo } from "../core/Schemas";
import { consolex } from "../core/Consolex";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { generateID } from "../core/Util";
import { getConfig, getServerConfig } from "../core/configuration/Config";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@@ -412,7 +414,7 @@ export class HostLobbyModal extends LitElement {
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${this.bots}"
.value="${String(this.bots)}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
@@ -508,7 +510,7 @@ export class HostLobbyModal extends LitElement {
public open() {
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
this.lobbyId = lobby.gameID;
// join lobby
})
.then(() => {
@@ -517,7 +519,7 @@ export class HostLobbyModal extends LitElement {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
gameID: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
@@ -582,21 +584,24 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
} as GameConfig),
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
}),
});
);
}
private async startGame() {
@@ -604,12 +609,15 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${getServerConfig().workerPath(this.lobbyId)}/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
});
);
}
private async copyToClipboard() {
@@ -623,34 +631,42 @@ export class HostLobbyModal extends LitElement {
this.copySuccess = false;
}, 2000);
} catch (err) {
consolex.error("Failed to copy text: ", err);
consolex.error(`Failed to copy text: ${err}`);
}
}
private async pollPlayers() {
fetch(`/lobby/${this.lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
.then((data: GameInfo) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
this.players = data.clients.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
async function createLobby(): Promise<GameInfo> {
const serverConfig = getServerConfig();
try {
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
"Content-Type": "application/json",
const id = generateID();
const response = await fetch(
`/${serverConfig.workerPath(id)}/create_game/${id}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
},
// body: JSON.stringify(data), // Include this if you need to send data
});
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@@ -659,13 +675,7 @@ async function createLobby(): Promise<Lobby> {
const data = await response.json();
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
id: data.id,
// Add other properties as needed
};
return lobby;
return data as GameInfo;
} catch (error) {
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
+18 -10
View File
@@ -2,6 +2,8 @@ import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { GameInfo } from "../core/Schemas";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@@ -358,13 +360,16 @@ export class JoinPrivateLobbyModal extends LitElement {
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
const url = `${window.location.origin}/${getServerConfig().workerPath(lobbyId)}/game/${lobbyId}/exists`;
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((response) => {
return response.json();
})
.then((data) => {
if (data.exists) {
this.message = "Joined successfully! Waiting for game to start...";
@@ -372,7 +377,7 @@ export class JoinPrivateLobbyModal extends LitElement {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
lobby: { gameID: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
@@ -394,15 +399,18 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
fetch(`/lobby/${this.lobbyIdInput.value}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`${getServerConfig().workerPath(this.lobbyIdInput.value)}/lobby/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
this.players = data.players.map((p) => p.username);
.then((data: GameInfo) => {
this.players = data.clients.map((p) => p.username);
})
.catch((error) => {
consolex.error("Error polling players:", error);
+8 -2
View File
@@ -1,4 +1,9 @@
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import {
Config,
GameEnv,
getServerConfig,
ServerConfig,
} from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import {
@@ -125,6 +130,7 @@ export class LocalServer {
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
const workerPath = getServerConfig().workerPath(this.lobbyConfig.gameID);
navigator.sendBeacon(`/${workerPath}/archive_singleplayer_game`, blob);
}
}
+2 -10
View File
@@ -65,10 +65,6 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener(
"single-player",
this.handleSinglePlayer.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
@@ -145,7 +141,7 @@ class Client {
gameType: gameType,
flag: (): string => this.flagInput.getCurrentFlag(),
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
gameID: lobby.gameID,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
@@ -161,7 +157,7 @@ class Client {
this.joinModal.close();
this.publicLobby.stop();
if (gameType != GameType.Singleplayer) {
window.history.pushState({}, "", `/join/${lobby.id}`);
window.history.pushState({}, "", `/join/${lobby.gameID}`);
sessionStorage.setItem("inLobby", "true");
}
},
@@ -177,10 +173,6 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
+10 -6
View File
@@ -1,16 +1,16 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Lobby } from "../core/Schemas";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
import { getMapsImage } from "./utilities/Maps";
import { GameInfo } from "../core/Schemas";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
private currLobby: GameInfo = null;
createRenderRoot() {
return this;
@@ -36,15 +36,16 @@ export class PublicLobby extends LitElement {
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
console.log(`got lobbies: ${JSON.stringify(lobbies)}`);
this.lobbies = lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
}
}
async fetchLobbies(): Promise<Lobby[]> {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch("/lobbies");
const response = await fetch("/public_lobbies");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
@@ -67,6 +68,9 @@ export class PublicLobby extends LitElement {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
// Format time to show minutes and seconds
@@ -121,7 +125,7 @@ export class PublicLobby extends LitElement {
this.currLobby = null;
}
private lobbyClicked(lobby: Lobby) {
private lobbyClicked(lobby: GameInfo) {
if (this.currLobby == null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
+1 -1
View File
@@ -424,7 +424,7 @@ export class SinglePlayerModal extends LitElement {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
gameID: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
+3 -2
View File
@@ -228,9 +228,10 @@ export class Transport {
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
+11 -9
View File
@@ -75,6 +75,17 @@ export type GameRecord = z.infer<typeof GameRecordSchema>;
const PlayerTypeSchema = z.nativeEnum(PlayerType);
export interface GameInfo {
gameID: GameID;
clients?: ClientInfo[];
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
}
export interface ClientInfo {
clientID: ClientID;
username: string;
}
export enum LogSeverity {
Debug = "DEBUG",
Info = "INFO",
@@ -83,15 +94,6 @@ export enum LogSeverity {
Fatal = "FATAL",
}
// TODO: create Cell schema
export interface Lobby {
id: string;
msUntilStart?: number;
numClients?: number;
gameConfig?: GameConfig;
}
const GameConfigSchema = z.object({
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
+6 -1
View File
@@ -15,7 +15,7 @@ import { Colord, colord } from "colord";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
import { consolex } from "../Consolex";
import { GameConfig } from "../Schemas";
import { GameConfig, GameID } from "../Schemas";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { GameMap, TileRef } from "../game/GameMap";
@@ -66,6 +66,11 @@ export interface ServerConfig {
gameCreationRate(): number;
lobbyLifetime(): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
workerPort(gameID: GameID): number;
workerPortByIndex(workerID: number): number;
env(): GameEnv;
}
+18 -5
View File
@@ -1,10 +1,8 @@
import { renderNumber } from "../../client/Utils";
import {
Difficulty,
Game,
GameType,
Gold,
MessageType,
Player,
PlayerInfo,
PlayerType,
@@ -14,16 +12,19 @@ import {
UnitInfo,
UnitType,
} from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { assertNever, within } from "../Util";
import { GameConfig, GameID } from "../Schemas";
import { assertNever, simpleHash, within } from "../Util";
import { Config, GameEnv, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
numWorkers(): number {
return 3;
}
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
@@ -35,6 +36,18 @@ export abstract class DefaultServerConfig implements ServerConfig {
lobbyLifetime(): number {
return 2 * 60 * 1000;
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
}
workerPath(gameID: GameID): string {
return `w${this.workerIndex(gameID)}`;
}
workerPort(gameID: GameID): number {
return this.workerPortByIndex(this.workerIndex(gameID));
}
workerPortByIndex(index: number): number {
return 3001 + index;
}
}
export class DefaultConfig implements Config {
+16 -110
View File
@@ -1,20 +1,12 @@
import { Config, ServerConfig } from "../core/configuration/Config";
import { ClientID, GameConfig, GameID } from "../core/Schemas";
import { v4 as uuidv4 } from "uuid";
import { ServerConfig } from "../core/configuration/Config";
import { GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
export class GameManager {
private lastNewLobby: number = 0;
private mapsPlaylist: GameMapType[] = [];
private games: GameServer[] = [];
private random = new PseudoRandom(123);
constructor(private config: ServerConfig) {}
public game(id: GameID): GameServer | null {
@@ -34,34 +26,20 @@ export class GameManager {
return false;
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find((g) => g.id == gameID);
if (game == null) {
console.warn(`game ${gameID} not found`);
return;
}
if (game.isPublic) {
console.warn(`cannot update public game ${gameID}`);
return;
}
game.updateGameConfig(gameConfig);
}
createPrivateGame(): string {
const id = generateID();
this.games.push(
new GameServer(id, Date.now(), false, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
bots: 400,
}),
);
return id;
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(id, Date.now(), this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
bots: 400,
...gameConfig, // TODO: make sure this works
});
this.games.push(game);
return game;
}
hasActiveGame(gameID: GameID): boolean {
@@ -73,83 +51,11 @@ export class GameManager {
return game.length > 0;
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find((g) => g.id == gameID);
console.log(`found game ${game}`);
if (game) {
game.start();
} else {
throw new Error(`cannot start private game, game ${gameID} not found`);
}
}
private getNextMap(): GameMapType {
if (this.mapsPlaylist.length > 0) {
return this.mapsPlaylist.shift();
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).map((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
this.mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
this.random.shuffleArray(this.mapsPlaylist);
if (this.allNonConsecutive(this.mapsPlaylist)) {
return this.mapsPlaylist.shift();
}
}
}
private allNonConsecutive(maps: GameMapType[]): boolean {
// Check for consecutive duplicates in the maps array
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby);
const active = this.gamesByPhase(GamePhase.Active);
const finished = this.gamesByPhase(GamePhase.Finished);
const now = Date.now();
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now;
lobbies.push(
new GameServer(generateID(), now, true, this.config, {
gameMap: this.getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
}),
);
}
active
.filter((g) => !g.hasStarted() && g.isPublic)
.forEach((g) => {
+26 -9
View File
@@ -1,24 +1,24 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import WebSocket from "ws";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameInfo,
Intent,
PlayerRecord,
ServerDesyncSchema,
ServerMessageSchema,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { archive } from "./Archive";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { Client } from "./Client";
import { slog } from "./StructuredLog";
export enum GamePhase {
Lobby = "LOBBY",
@@ -51,7 +51,6 @@ export class GameServer {
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
public gameConfig: GameConfig,
) {}
@@ -360,7 +359,7 @@ export class GameServer {
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
if (!this.isPublic) {
if (this.gameConfig.gameType != GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
@@ -389,6 +388,24 @@ export class GameServer {
return this._hasStarted;
}
public gameInfo(): GameInfo {
return {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.lobbyLifetime()
: undefined,
};
}
public isPublic(): boolean {
return this.gameConfig.gameType == GameType.Public;
}
private maybeSendDesync() {
if (this.activeClients.length <= 1) {
return;
+255
View File
@@ -0,0 +1,255 @@
import cluster from "cluster";
import http from "http";
import express from "express";
import { GameMapType, GameType, Difficulty } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameEnv, getServerConfig } from "../core/configuration/Config";
import { GameInfo } from "../core/Schemas";
import path from "path";
import rateLimit from "express-rate-limit";
import { fileURLToPath } from "url";
const config = getServerConfig();
const app = express();
const server = http.createServer(app);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 2);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
let publicLobbiesJsonStr = "";
let publicLobbyIDs: Set<string> = new Set();
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
throw new Error(
"startMaster() should only be called in the primary process",
);
}
console.log(`Primary ${process.pid} is running`);
console.log(`Setting up ${config.numWorkers()} workers...`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
});
console.log(`Started worker ${i} (PID: ${worker.process.pid})`);
}
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (!workerId) {
console.error(`worker crashed could not find id`);
return;
}
console.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
console.log(`Restarting worker ${workerId}...`);
// Restart the worker with the same ID
const newWorker = cluster.fork({
WORKER_ID: workerId,
});
console.log(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Master HTTP server listening on port ${PORT}`);
});
sleep(5000).then(() => {
// let the workers start up
const scheduleLobbies = () => {
schedulePublicGame().catch((error) => {
console.error("Error scheduling public game:", error);
});
};
scheduleLobbies();
setInterval(scheduleLobbies, config.gameCreationRate());
setInterval(() => fetchLobbies(), 250);
});
}
// Add lobbies endpoint to list public games for this worker
app.get("/public_lobbies", (req, res) => {
res.send(publicLobbiesJsonStr);
});
async function fetchLobbies(): Promise<void> {
const fetchPromises = [];
for (const gameID of publicLobbyIDs) {
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/game/${gameID}`)
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
})
.catch((error) => {
console.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
return null;
});
fetchPromises.push(promise);
}
// Wait for all promises to resolve
const results = await Promise.all(fetchPromises);
// Filter out any null results from failed fetches
const lobbyInfos: GameInfo[] = results
.filter((result) => result !== null)
.map((gi: GameInfo) => {
return {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (l.msUntilStart <= 250) {
publicLobbyIDs.delete(l.gameID);
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
lobbies: lobbyInfos,
});
}
// Function to schedule a new public game
async function schedulePublicGame() {
const gameID = generateID();
publicLobbyIDs.add(gameID);
// Create the default public game config (from your GameManager)
const defaultGameConfig = {
gameMap: getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "true", // Special header for internal requests
},
body: JSON.stringify({
gameID: gameID,
gameConfig: defaultGameConfig,
}),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
const data = await response.json();
} catch (error) {
console.error(
`Failed to schedule public game on worker ${workerPath}:`,
error,
);
throw error;
}
}
// Map rotation management (moved from GameManager)
let mapsPlaylist: GameMapType[] = [];
const random = new PseudoRandom(123);
// Get the next map in rotation
function getNextMap(): GameMapType {
if (mapsPlaylist.length > 0) {
return mapsPlaylist.shift()!;
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).forEach((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
random.shuffleArray(mapsPlaylist);
if (allNonConsecutive(mapsPlaylist)) {
return mapsPlaylist.shift()!;
}
}
}
// Check for consecutive duplicates in the maps array
function allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
+18 -480
View File
@@ -1,484 +1,22 @@
import express, { json, Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import {
ClientMessage,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
LogSeverity,
ServerStartGameMessageSchema,
} from "../core/Schemas";
import {
GameEnv,
getConfig,
getServerConfig,
} from "../core/configuration/Config";
import { slog } from "./StructuredLog";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { archive, gameRecordExists, readGameRecord } from "./Archive";
import { DiscordBot } from "./DiscordBot";
import {
sanitizeUsername,
validateUsername,
} from "../core/validations/username";
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import dotenv from "dotenv";
import crypto from "crypto";
dotenv.config();
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import cluster from "cluster";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const serverConfig = getServerConfig();
// Initialize Secret Manager
const secretManager = new SecretManagerServiceClient();
// Discord OAuth Configuration (will be populated from secrets)
let DISCORD_CLIENT_ID: string;
let DISCORD_CLIENT_SECRET: string;
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 2);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
const rateLimiter = new RateLimiterMemory({
points: 50, // 50 messages
duration: 1, // per 1 second
});
const updateRateLimiter = new RateLimiterMemory({
points: 10,
duration: 240, // 4 minutes
});
const gm = new GameManager(getServerConfig());
const bot = new DiscordBot();
try {
await bot.start();
} catch (error) {
console.error("Failed to start bot:", error);
}
let lobbiesString = "";
// Async error wrapper with rate limiting support
const asyncHandler =
(fn: Function, limiter = null) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
// Apply rate limiting if a limiter is provided
if (limiter) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
try {
await limiter.consume(clientIP);
} catch (error) {
console.warn(`Rate limited for IP ${clientIP}`);
return res.status(429).json({ error: "Too many requests" });
}
}
// Execute the route handler
await fn(req, res, next);
} catch (error) {
// Pass any errors to Express error handler
next(error);
}
};
// Discord OAuth callback endpoint
app.get(
"/auth/callback",
asyncHandler(async (req: Request, res: Response) => {
const { code } = req.query;
if (!code) {
return res.status(400).send("No code provided");
}
// Exchange code for access token
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID!,
client_secret: DISCORD_CLIENT_SECRET!,
code: code as string,
grant_type: "authorization_code",
redirect_uri: serverConfig.discordRedirectURI(),
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!tokenResponse.ok) {
throw new Error("Failed to get access token");
}
const tokenData = await tokenResponse.json();
// Get user information
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
if (!userResponse.ok) {
throw new Error("Failed to get user information");
}
const userData = await userResponse.json();
const sessionToken = crypto.randomBytes(32).toString("hex");
// TODO: store userData and sessionToken in database.
res.cookie("session", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
});
res.redirect(`/`);
}),
);
app.get("/auth/discord", (req: Request, res: Response) => {
console.log("Redirecting to Discord OAuth...");
const redirectUri = serverConfig.discordRedirectURI();
const authorizeUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify`;
console.log("Auth URL:", authorizeUrl);
res.redirect(authorizeUrl);
});
// New GET endpoint to list lobbies
app.get("/lobbies", (req: Request, res: Response) => {
res.send(lobbiesString);
});
app.post(
"/private_lobby",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const id = gm.createPrivateGame();
console.log(`ip ${clientIP} creating private lobby with id ${id}`);
res.json({
id: id,
});
}, updateRateLimiter),
);
app.post(
"/archive_singleplayer_game",
asyncHandler(async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
archive(gameRecord);
res.json({
success: true,
});
}, updateRateLimiter),
);
app.post(
"/start_private_lobby/:id",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(`starting private lobby with id ${req.params.id}`);
gm.startPrivateGame(req.params.id);
res.status(200).json({ success: true });
}, updateRateLimiter),
);
app.put(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
const lobbyID = req.params.id;
gm.updateGameConfig(lobbyID, {
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/lobby/:id/exists",
asyncHandler(async (req, res) => {
const lobbyId = req.params.id;
let gameExists = gm.hasActiveGame(lobbyId);
if (!gameExists) {
gameExists = await gameRecordExists(lobbyId);
}
res.json({
exists: gameExists,
});
}),
);
app.get(
"/lobby/:id",
asyncHandler(async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json({
players: game.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
});
}),
);
app.get(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
res.json({
hi: "5",
});
}),
);
app.get(
"/debug-ip",
asyncHandler(async (req, res) => {
res.send({
"x-forwarded-for": req.headers["x-forwarded-for"],
"real-ip": req.ip,
"raw-headers": req.rawHeaders,
});
}),
);
app.get("*", function (req, res) {
// SPA routing
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
wss.on("connection", (ws, req) => {
ws.on("message", async (message: string) => {
let ip = "";
try {
const forwarded = req.headers["x-forwarded-for"];
ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
await rateLimiter.consume(ip);
} catch (error) {
console.warn(`rate limit exceede for ${ip}`);
return;
}
try {
let clientMsg: ClientMessage = null;
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message.toString()));
} catch (error) {
throw new Error(`error parsing zod schema for ip: ${ip}`);
}
if (clientMsg.type == "join") {
const forwarded = req.headers["x-forwarded-for"];
let ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0];
}
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(
`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`,
);
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username);
const wasFound = gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
),
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(`game ${clientMsg.gameID} not found, loading from gcs`);
const record = await readGameRecord(clientMsg.gameID);
let startGame = null;
try {
startGame = ServerStartGameMessageSchema.parse({
type: "start",
turns: record.turns,
config: record.gameConfig,
});
} catch (error) {
console.log(`error validating schema for ip ${ip}`);
}
ws.send(JSON.stringify(startGame));
}
}
if (clientMsg.type == "log") {
slog({
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
});
}
} catch (error) {
console.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
0,
250,
),
);
}
});
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
function startServer() {
setInterval(() => tick(), 1000);
setInterval(() => updateLobbies(), 100);
initializeSecrets();
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
function tick() {
gm.tick();
}
function updateLobbies() {
lobbiesString = JSON.stringify({
lobbies: gm
.gamesByPhase(GamePhase.Lobby)
.filter((g) => g.isPublic)
.map((g) => ({
id: g.id,
msUntilStart: g.startTime() - Date.now(),
numClients: g.numClients(),
gameConfig: g.gameConfig,
}))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
}
// Process-level unhandled exception handlers
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
slog({
logKey: "uncaught_exception",
msg: `Uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
// Note: We're not exiting the process to maintain uptime
// but be aware the app might be in an inconsistent state
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
slog({
logKey: "unhandled_rejection",
msg: `Unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
// Initialize secrets and start server
async function initializeSecrets() {
try {
DISCORD_CLIENT_ID = await getSecret(
"DISCORD_CLIENT_ID",
serverConfig.env(),
);
DISCORD_CLIENT_SECRET = await getSecret(
"DISCORD_CLIENT_SECRET",
serverConfig.env(),
);
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
throw new Error("Failed to load Discord secrets");
}
} catch (error) {
console.error("Failed to initialize secrets:", error);
// Main entry point of the application
async function main() {
// Check if this is the primary (master) process
if (cluster.isPrimary) {
console.log("Starting master process...");
await startMaster();
} else {
// This is a worker process
console.log("Starting worker process...");
await startWorker();
}
}
async function getSecret(secretName: string, ge: GameEnv) {
if (ge == GameEnv.Dev) {
console.log(`loading secret ${secretName} from environment variable`);
const value = process.env[secretName];
if (!value) {
throw Error(`error loading secret ${secretName}`);
}
}
console.log(`loading secret ${secretName} from Google secrets manager`);
const name = `projects/openfrontio/secrets/${secretName}/versions/latest`;
const [version] = await secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString();
}
startServer();
// Start the application
main().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
+374
View File
@@ -0,0 +1,374 @@
import express, { Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import { getServerConfig } from "../core/configuration/Config";
import { WebSocket } from "ws";
import { Client } from "./Client";
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
import { slog } from "./StructuredLog";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
const config = getServerConfig();
// Worker setup
export function startWorker() {
// Get worker ID from environment variable
const workerId = parseInt(process.env.WORKER_ID || "0");
console.log(`Worker ${workerId} starting...`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const gm = new GameManager(config);
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
// Extract the original path without the worker prefix
const originalPath = req.url;
const match = originalPath.match(/^\/w(\d+)(.*)$/);
if (match) {
const pathWorkerId = parseInt(match[1]);
const actualPath = match[2] || "/";
// Verify this request is for the correct worker
if (pathWorkerId !== workerId) {
return res.status(404).json({
error: "Worker mismatch",
message: `This is worker ${workerId}, but you requested worker ${pathWorkerId}`,
});
}
// Update the URL to remove the worker prefix
req.url = actualPath;
}
next();
});
app.set("trust proxy", 2);
app.use(express.json());
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
const rateLimiter = new RateLimiterMemory({
points: 50, // 50 messages
duration: 1, // per 1 second
});
const updateRateLimiter = new RateLimiterMemory({
points: 10,
duration: 240, // 4 minutes
});
// Async handler with rate limiting
const asyncHandler =
(fn: Function, limiter = null) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
if (limiter) {
if (!isLocalhost(req)) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
try {
await limiter.consume(clientIP);
} catch (error) {
console.warn(`Rate limited for IP ${clientIP}`);
return res.status(429).json({ error: "Too many requests" });
}
}
}
await fn(req, res, next);
} catch (error) {
next(error);
}
};
// Endpoint to create a private lobby
app.post(
"/create_game/:id",
asyncHandler(async (req, res) => {
const id = req.params.id;
if (!id) {
console.warn(`cannot create game, id not found`);
return;
}
// TODO: if game is public make sure request came from localhohst!!!
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const gc = req.body?.gameConfig as GameConfig;
if (gc?.gameType == GameType.Public && !isLocalhost(req)) {
console.warn(
`cannot create public game ${id}, ip ${clientIP} not localhost`,
);
return res.status(400);
}
// Double-check this worker should host this game
const expectedWorkerId = config.workerIndex(id);
if (expectedWorkerId !== workerId) {
console.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400);
}
const game = gm.createGame(id, gc);
console.log(
`Worker ${workerId}: IP ${clientIP} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
);
res.json(game.gameInfo());
}, updateRateLimiter),
);
// Add other endpoints from your original server
app.post(
"/start_game/:id",
asyncHandler(async (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
const game = gm.game(req.params.id);
if (!game) {
return;
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(
`cannot start public game ${game.id}, game is public, ip: ${clientIP}`,
);
return;
}
game.start();
res.status(200).json({ success: true });
}, updateRateLimiter),
);
app.put(
"/game/:id",
asyncHandler(async (req, res) => {
// TODO: only update public game if from local host
const lobbyID = req.params.id;
if (req.body.gameType == GameType.Public) {
console.log(`cannot update game ${lobbyID} to public`);
return res.status(400);
}
const game = gm.game(lobbyID);
if (!game) {
return res.status(400);
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.warn(`cannot update public game ${game.id}, ip: ${clientIP}`);
return res.status(400);
}
game.updateGameConfig({
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/game/:id/exists",
asyncHandler(async (req, res) => {
const lobbyId = req.params.id;
console.log(`checking if game ${lobbyId} exists`);
let gameExists = gm.hasActiveGame(lobbyId);
res.json({
exists: gameExists,
});
}),
);
app.get(
"/game/:id",
asyncHandler(async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json(game.gameInfo());
}),
);
app.post(
"/archive_singleplayer_game",
asyncHandler(async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
archive(gameRecord);
res.json({
success: true,
});
}, updateRateLimiter),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on("message", async (message: string) => {
const forwarded = req.headers["x-forwarded-for"];
const ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
try {
await rateLimiter.consume(ip);
} catch (error) {
console.warn(`rate limit exceeded for ${ip}`);
return;
}
try {
// Process WebSocket messages as in your original code
// Parse and handle client messages
const clientMsg = JSON.parse(message.toString());
if (clientMsg.type == "join") {
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
console.warn(
`Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return;
}
// Create client and add to game
const client = new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
);
const wasFound = gm.addClient(
client,
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
// Handle game not found case
}
}
// Handle other message types
} catch (error) {
console.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
0,
250,
),
);
}
});
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Set up ticker
setInterval(() => gm.tick(), 1000);
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => {
console.log(`Worker ${workerId} running on http://localhost:${PORT}`);
console.log(`Handling requests with path prefix /w${workerId}/`);
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
// Process-level error handlers
process.on("uncaughtException", (err) => {
console.error(`Worker ${workerId} uncaught exception:`, err);
slog({
logKey: "uncaught_exception",
msg: `Worker ${workerId} uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
});
process.on("unhandledRejection", (reason, promise) => {
console.error(
`Worker ${workerId} unhandled rejection at:`,
promise,
"reason:",
reason,
);
slog({
logKey: "unhandled_rejection",
msg: `Worker ${workerId} unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
}
const isLocalhost = (req: Request): boolean => {
// Get client IP address from various possible sources
const clientIP =
req.ip ||
req.socket.remoteAddress ||
(req.headers["x-forwarded-for"] as string)?.split(",").shift() ||
"unknown";
// Check if the request is from a loopback address
const isLoopbackIP =
// IPv4 localhost
clientIP === "127.0.0.1" ||
// IPv6 localhost
clientIP === "::1" ||
// Full loopback range
clientIP.startsWith("127.");
// Check hostname
const isLocalHostname =
req.hostname === "localhost" || req.headers.host?.startsWith("localhost:");
// Consider request local if either IP is loopback or hostname is localhost
return isLoopbackIP || isLocalHostname;
};
+57 -6
View File
@@ -123,21 +123,72 @@ export default (env, argv) => {
compress: true,
port: 9000,
proxy: [
// WebSocket proxies
{
context: ["/socket"],
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
logLevel: "debug",
},
// Worker WebSocket proxies - using direct paths without /socket suffix
{
context: ["/w0"],
target: "ws://localhost:3001",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "ws://localhost:3002",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "ws://localhost:3003",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Worker proxies for HTTP requests
{
context: ["/w0"],
target: "http://localhost:3001",
pathRewrite: { "^/w0": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "http://localhost:3002",
pathRewrite: { "^/w1": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "http://localhost:3003",
pathRewrite: { "^/w2": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Original API endpoints
{
context: [
"/lobbies",
"/public_lobbies",
"/join_game",
"/join_lobby",
"/private_lobby",
"/start_private_lobby",
"/lobby",
"/start_game",
"/create_game",
"/archive_singleplayer_game",
"/validate-username",
"/debug-ip",
"/auth/callback",
"/auth/discord",