mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
use node cluster to shard server
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -424,7 +424,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
detail: {
|
||||
gameType: GameType.Singleplayer,
|
||||
lobby: {
|
||||
id: generateID(),
|
||||
gameID: generateID(),
|
||||
},
|
||||
map: this.selectedMap,
|
||||
difficulty: this.selectedDifficulty,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user