Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1219,5 +1219,17 @@
"fullscreen": {
"enter": "Enter fullscreen",
"exit": "Exit fullscreen"
},
"worker_error": {
"account_banned": "Account Banned",
"cannot_join_game": "Cannot join game",
"connection_refused": "Connection refused",
"forbidden": "Forbidden",
"game_not_found": "Game not found",
"lobby_full": "Lobby full",
"turnstile_invalid": "Unauthorized: invalid token",
"turnstile_fix_tips": "Try this to fix: \n 1. Do a hard refresh (Mac: Cmd+Shift+R, Windows: Ctrl+F5) \n 2. Or resync your device clock \n 3. Or close all browser windows and restart your browser \n 4. Or try another browser, preferably without extensions \n 5. Or clear site data for this site",
"unauthorized": "Unauthorized",
"user_me_fetch_failed": "Unauthorized: user fetch failed"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
47 changes: 46 additions & 1 deletion src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ import {
ServerMessage,
ServerMessageSchema,
Winner,
WSError,
WSErrorSchema,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { getPlayToken } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
import { getEnglishText, translateText } from "./Utils";

export class PauseGameIntentEvent implements GameEvent {
constructor(public readonly paused: boolean) {}
Expand Down Expand Up @@ -177,6 +180,7 @@ export class SendStartGameEvent implements GameEvent {}

export class Transport {
private socket: WebSocket | null = null;
private disconnectWSError: WSError | null = null;

private localServer: LocalServer;

Expand Down Expand Up @@ -333,6 +337,7 @@ export class Transport {
const workerPath = this.lobbyConfig.serverConfig.workerPath(
this.lobbyConfig.gameID,
);
this.disconnectWSError = null;
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
Expand All @@ -358,6 +363,12 @@ export class Transport {
const parsed = JSON.parse(event.data);
const result = ServerMessageSchema.safeParse(parsed);
if (!result.success) {
const wsErrorResult = WSErrorSchema.safeParse(parsed);
if (wsErrorResult.success) {
this.disconnectWSError = wsErrorResult.data;
return;
}

const error = z.prettifyError(result.error);
console.error("Error parsing server message", error);
return;
Expand All @@ -378,8 +389,42 @@ export class Transport {
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
);
if (event.code === 1002) {
const connRefusedKey = `worker_error.connection_refused`;
Copy link
Copy Markdown
Collaborator

@evanpelle evanpelle May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit messy. I'm thinking we do something like create a zod schema for websocket error and it's just something like:

const ErrorSchema = z.object({
translationKey: z.string()
args: z.record<z.string(),z.string()>
})

there's a 123 byte limit, so maybe we should send the error and then close:

import { z } from "zod";
import type { WebSocket } from "ws";

export const WSErrorSchema = z.object({
translationKey: z.string(),
args: z.record(z.string(), z.string()).optional(),
});

export type WSError = z.infer;

export function sendErrorAndClose(ws: WebSocket, error: WSError, code = 4000) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(error));
}
ws.close(code);
}

Copy link
Copy Markdown
Contributor Author

@VariableVince VariableVince May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it. Don't think it is much less 'messy'. Because we still need to test stuff, since we don't know if A) we recieved an actual translation key and not just a raw error string like "ClientJoinMessageSchema", "WS_ERR_UNEXPECTED_RSV_1" and in cosmetics.reason and B) if we did recieve a translation key, the translation may be missing from both the user's labguage JSON and en.json and translateText will send back the translation key as a result.

To clarify it a bit more, i could rename "translationKey" in WSErrorSchema and the const in Transport.ts to something like "translationKeyOrError" because it can contain both.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah let's just not support args and then it's really simple like:

ws.close(1002, worker_error.game_not_found)

and then here we just do:

alert(translateText(ws.reason))

const translationKey = this.disconnectWSError
? this.disconnectWSError.translationKey
: event.reason;
const args = this.disconnectWSError
? this.disconnectWSError.args
: undefined;

const errorKey = `worker_error.${translationKey}`;

let alertMsg = `${translateText(connRefusedKey)}: `;
const translatedError = translateText(errorKey, args);

if (translatedError === errorKey) {
// Raw string instead of translation key in disconnectWSError/error.reason,
// or no user lang nor default English translation found with the key
// Show the raw string or key as fallback. Eg. "WS_ERR_UNEXPECTED_RSV_1" or "ClientJoinMessageSchema"
alertMsg += `${translationKey}`;
} else {
alertMsg += translatedError;

// Add tips if turnstile token invalid
if (translationKey === "turnstile_invalid") {
alertMsg += `\n${translateText("worker_error.turnstile_fix_tips")}`;
}

// Append English translation/key if it's not already there
// Helps debugging if user shares screenshot
const englishError = getEnglishText(errorKey, args);
if (englishError !== errorKey && !alertMsg.includes(englishError)) {
alertMsg += `\n\n--- English ---\n${getEnglishText(connRefusedKey)}: ${englishError}`;
}
}

// TODO: make this a modal
alert(`connection refused: ${event.reason}`);
alert(alertMsg);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (event.code !== 1000) {
console.log(`received error code ${event.code}, reconnecting`);
this.reconnect();
Expand Down
10 changes: 9 additions & 1 deletion src/client/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,9 +407,17 @@ function getCachedLangSelector(): LangSelector | null {
return found;
}

export function getEnglishText(
key: string,
params?: Record<string, string | number>,
): string {
return translateText(key, params, true);
}

export const translateText = (
key: string,
params?: Record<string, string | number>,
getOnlyEnglish: boolean = false,
): string => {
const self = translateText as any;
self.formatterCache ??= new Map();
Expand All @@ -436,7 +444,7 @@ export const translateText = (
self.lastLang = langSelector.currentLang;
}

let message = translations?.[key];
let message = getOnlyEnglish ? undefined : translations?.[key];
const hasPrimaryTranslation = message !== undefined;

message ??= defaultTranslations?.[key];
Expand Down
6 changes: 6 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,12 @@ export const ServerErrorSchema = z.object({
message: z.string().optional(),
});

export const WSErrorSchema = z.object({
translationKey: z.string(),
args: z.record(z.string(), z.string()).optional(),
});
export type WSError = z.infer<typeof WSErrorSchema>;

export const ServerLobbyInfoMessageSchema = z.object({
type: z.literal("lobby_info"),
lobby: GameInfoSchema,
Expand Down
5 changes: 4 additions & 1 deletion src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
import { sendErrorAndClose } from "./Worker";
export enum GamePhase {
Lobby = "LOBBY",
Active = "ACTIVE",
Expand Down Expand Up @@ -613,7 +614,9 @@ export class GameServer {
});
client.ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
sendErrorAndClose(client.ws, {
translationKey: "WS_ERR_UNEXPECTED_RSV_1",
});
}
});

Expand Down
34 changes: 21 additions & 13 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
GameID,
PartialGameRecordSchema,
ServerErrorMessage,
WSError,
} from "../core/Schemas";
import { generateID, replacer } from "../core/Util";
import { CreateGameInputSchema } from "../core/WorkerSchemas";
Expand All @@ -34,6 +35,13 @@ import { verifyTurnstileToken } from "./Turnstile";
import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";

export function sendErrorAndClose(ws: WebSocket, error: WSError, code = 1002) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(error));
}
ws.close(code);
}

const config = getServerConfigFromServer();

const workerId = parseInt(process.env.WORKER_ID ?? "0");
Expand Down Expand Up @@ -300,7 +308,7 @@ export async function startWorker() {
error: error.toString(),
} satisfies ServerErrorMessage),
);
ws.close(1002, "ClientJoinMessageSchema");
sendErrorAndClose(ws, { translationKey: "ClientJoinMessageSchema" });
Comment thread
VariableVince marked this conversation as resolved.
return;
}
const clientMsg = parsed.data;
Expand Down Expand Up @@ -330,13 +338,13 @@ export async function startWorker() {
log.warn(`Invalid token: ${result.message}`, {
gameID: clientMsg.gameID,
});
ws.close(1002, `Unauthorized: invalid token`);
sendErrorAndClose(ws, { translationKey: "turnstile_invalid" });
return;
}
const { persistentId, claims } = result;

if (claims?.role === "banned") {
ws.close(1002, "Account Banned");
sendErrorAndClose(ws, { translationKey: "account_banned" });
return;
}

Expand All @@ -355,7 +363,7 @@ export async function startWorker() {
log.warn(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
ws.close(1002, "Game not found");
sendErrorAndClose(ws, { translationKey: "game_not_found" });
}
return;
}
Expand Down Expand Up @@ -385,7 +393,7 @@ export async function startWorker() {
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
ws.close(1002, "Unauthorized");
sendErrorAndClose(ws, { translationKey: "unauthorized" });
return;
}
} else {
Expand All @@ -396,7 +404,7 @@ export async function startWorker() {
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(1002, "Unauthorized: user me fetch failed");
sendErrorAndClose(ws, { translationKey: "user_me_fetch_failed" });
return;
}
flares = result.response.player.flares;
Expand All @@ -409,7 +417,7 @@ export async function startWorker() {
log.warn(
"Forbidden: player without an allowed flare attempted to join game",
);
ws.close(1002, "Forbidden");
sendErrorAndClose(ws, { translationKey: "forbidden" });
return;
}
}
Expand All @@ -424,7 +432,7 @@ export async function startWorker() {
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(1002, cosmeticResult.reason);
sendErrorAndClose(ws, { translationKey: cosmeticResult.reason });
return;
}

Expand All @@ -443,7 +451,7 @@ export async function startWorker() {
gameID: clientMsg.gameID,
reason: turnstileResult.reason,
});
ws.close(1002, "Unauthorized: Turnstile token rejected");
sendErrorAndClose(ws, { translationKey: "turnstile_invalid" });
return;
case "error":
// Fail open, allow the client to join.
Expand Down Expand Up @@ -473,19 +481,19 @@ export async function startWorker() {

if (joinResult === "not_found") {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
ws.close(1002, "Game not found");
sendErrorAndClose(ws, { translationKey: "game_not_found" });
} else if (joinResult === "kicked") {
log.warn(`kicked client tried to join game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Cannot join game");
sendErrorAndClose(ws, { translationKey: "cannot_join_game" });
} else if (joinResult === "rejected") {
log.info(`client rejected from game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Lobby full");
sendErrorAndClose(ws, { translationKey: "lobby_full" });
}

// Handle other message types
Expand All @@ -502,7 +510,7 @@ export async function startWorker() {

ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
sendErrorAndClose(ws, { translationKey: "WS_ERR_UNEXPECTED_RSV_1" });
}
});
ws.on("close", () => {
Expand Down
Loading