From eb34fe8d880b38300767f4d9a2e318b34764ddd4 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 8 May 2026 22:12:23 +0200 Subject: [PATCH] WSErrorSchema --- src/client/Transport.ts | 40 ++++++++++++++++++++++++++++++---------- src/core/Schemas.ts | 6 ++++++ src/server/GameServer.ts | 5 ++++- src/server/Worker.ts | 34 +++++++++++++++++++++------------- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c510aa0cb..fcc596a44 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -24,6 +24,8 @@ import { ServerMessage, ServerMessageSchema, Winner, + WSError, + WSErrorSchema, } from "../core/Schemas"; import { replacer } from "../core/Util"; import { getPlayToken } from "./Auth"; @@ -178,6 +180,7 @@ export class SendStartGameEvent implements GameEvent {} export class Transport { private socket: WebSocket | null = null; + private disconnectWSError: WSError | null = null; private localServer: LocalServer; @@ -334,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; @@ -359,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; @@ -380,26 +390,36 @@ export class Transport { ); if (event.code === 1002) { const connRefusedKey = `worker_error.connection_refused`; - const errorKey = `worker_error.${event.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); + const translatedError = translateText(errorKey, args); if (translatedError === errorKey) { - // No translation key in error.reason or no translation or default English found - alertMsg += `${event.reason}`; + // 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 token invalid - if (event.reason === "turnstile_invalid") { + // Add tips if turnstile token invalid + if (translationKey === "turnstile_invalid") { alertMsg += `\n${translateText("worker_error.turnstile_fix_tips")}`; } - // Append English translation if it differs - const englishMsg = getEnglishText(errorKey); - if (englishMsg !== errorKey && !alertMsg.includes(englishMsg)) { - alertMsg += `\n\n--- English ---\n${getEnglishText(connRefusedKey)}: ${englishMsg}`; + // 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}`; } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4a1636e19..2c41cdc77 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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; + export const ServerLobbyInfoMessageSchema = z.object({ type: z.literal("lobby_info"), lobby: GameInfoSchema, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 169c15972..769917e78 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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", @@ -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", + }); } }); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index b6855c394..302638178 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -14,6 +14,7 @@ import { GameID, PartialGameRecordSchema, ServerErrorMessage, + WSError, } from "../core/Schemas"; import { generateID, replacer } from "../core/Util"; import { CreateGameInputSchema } from "../core/WorkerSchemas"; @@ -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"); @@ -300,7 +308,7 @@ export async function startWorker() { error: error.toString(), } satisfies ServerErrorMessage), ); - ws.close(1002, "ClientJoinMessageSchema"); + sendErrorAndClose(ws, { translationKey: "ClientJoinMessageSchema" }); return; } const clientMsg = parsed.data; @@ -330,13 +338,13 @@ export async function startWorker() { log.warn(`Invalid token: ${result.message}`, { gameID: clientMsg.gameID, }); - ws.close(1002, "turnstile_invalid"); + 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; } @@ -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; } @@ -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 { @@ -396,7 +404,7 @@ export async function startWorker() { persistentID: persistentId, gameID: clientMsg.gameID, }); - ws.close(1002, "user_me_fetch_failed"); + sendErrorAndClose(ws, { translationKey: "user_me_fetch_failed" }); return; } flares = result.response.player.flares; @@ -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; } } @@ -424,7 +432,7 @@ export async function startWorker() { persistentID: persistentId, gameID: clientMsg.gameID, }); - ws.close(1002, cosmeticResult.reason); + sendErrorAndClose(ws, { translationKey: cosmeticResult.reason }); return; } @@ -443,7 +451,7 @@ export async function startWorker() { gameID: clientMsg.gameID, reason: turnstileResult.reason, }); - ws.close(1002, "turnstile_invalid"); + sendErrorAndClose(ws, { translationKey: "turnstile_invalid" }); return; case "error": // Fail open, allow the client to join. @@ -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 @@ -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", () => {