diff --git a/resources/lang/en.json b/resources/lang/en.json index a2c7324eb8..95d9cec357 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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" } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1e4526a8c2..fcc596a440 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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) {} @@ -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; @@ -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; @@ -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; @@ -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`; + 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); } else if (event.code !== 1000) { console.log(`received error code ${event.code}, reconnecting`); this.reconnect(); diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 943e764a4a..e3c4682c63 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -407,9 +407,17 @@ function getCachedLangSelector(): LangSelector | null { return found; } +export function getEnglishText( + key: string, + params?: Record, +): string { + return translateText(key, params, true); +} + export const translateText = ( key: string, params?: Record, + getOnlyEnglish: boolean = false, ): string => { const self = translateText as any; self.formatterCache ??= new Map(); @@ -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]; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4a1636e195..2c41cdc774 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 169c159722..769917e785 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 13cf1f32c2..3026381788 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, `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; } @@ -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, "Unauthorized: 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, "Unauthorized: Turnstile token rejected"); + 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", () => {