WSErrorSchema

This commit is contained in:
VariableVince
2026-05-08 22:12:23 +02:00
parent 460c456bbd
commit eb34fe8d88
4 changed files with 61 additions and 24 deletions
+30 -10
View File
@@ -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}`;
}
}
+6
View File
@@ -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,
+4 -1
View File
@@ -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",
});
}
});
+21 -13
View File
@@ -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", () => {