On disconnect, a player's slot is held for 60s via ReconnectManager. During the grace window, game.delta frames destined for the absent slot are buffered in order. If the client reconnects with its original token (envelope-level), the grace timer is cancelled and the server replays a fresh game.state snapshot plus every buffered delta. Timer expiry triggers the original "player_left" game.end cleanup that previously ran immediately on disconnect. - new packages/server/src/reconnect.ts: ReconnectManager (no WS refs, no registry coupling, unref'd timers so tests don't block exit) - broadcast.ts: unregisterConnection starts grace; handleRoomJoin routes reconnect path via envelope token + isPending check; handleGameMove buffers deltas for disconnected opponents - reconnect.test.ts: 9 unit cases (grace/cancel/buffer/expire/reset) - broadcast.test.ts: end-to-end reconnect scenario + negative case
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
// @paratype/chess-server — authoritative Bun WebSocket server
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
handleMessage,
|
|
registerConnection,
|
|
unregisterConnection,
|
|
type ClientData,
|
|
} from "./broadcast.js";
|
|
import { logger } from "./logger.js";
|
|
import { renderMetrics } from "./logging.js";
|
|
import {
|
|
checkMessageSize,
|
|
checkOrigin,
|
|
MAX_MESSAGE_BYTES,
|
|
RateLimiter,
|
|
} from "./middleware.js";
|
|
|
|
const port = parseInt(process.env["PORT"] ?? "7357", 10);
|
|
|
|
export const server = Bun.serve<ClientData>({
|
|
port,
|
|
fetch(req, srv) {
|
|
const url = new URL(req.url);
|
|
|
|
if (url.pathname === "/ws") {
|
|
// Origin check runs before upgrade so browser clients from
|
|
// disallowed origins never see a 101 response. Tests bypass the
|
|
// check by setting ALLOWED_ORIGINS or by omitting the header
|
|
// (Bun's WebSocket client sends no Origin by default, which is
|
|
// treated as same-origin and accepted in test environments).
|
|
const origin = req.headers.get("origin");
|
|
if (origin !== null && !checkOrigin(origin)) {
|
|
return new Response("Forbidden origin", { status: 403 });
|
|
}
|
|
|
|
const upgraded = srv.upgrade(req, {
|
|
data: {
|
|
clientId: randomUUID(),
|
|
} satisfies ClientData,
|
|
});
|
|
if (upgraded) return undefined;
|
|
return new Response("Upgrade failed", { status: 500 });
|
|
}
|
|
|
|
if (url.pathname === "/healthz") {
|
|
return Response.json({ ok: true, version: "1.0.0" });
|
|
}
|
|
|
|
if (url.pathname === "/metrics") {
|
|
return new Response(renderMetrics(), {
|
|
headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
return new Response("Not Found", { status: 404 });
|
|
},
|
|
websocket: {
|
|
open(ws) {
|
|
registerConnection(ws);
|
|
logger.child({ clientId: ws.data.clientId }).info("ws connected");
|
|
},
|
|
message(ws, msg) {
|
|
// Size gate — we must enforce this before parsing to avoid
|
|
// allocating large JSON trees from a hostile client.
|
|
if (!checkMessageSize(msg)) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: Date.now(),
|
|
type: "error",
|
|
payload: {
|
|
code: "MSG_TOO_LARGE",
|
|
message: `frame exceeds ${String(MAX_MESSAGE_BYTES)} bytes`,
|
|
fatal: true,
|
|
},
|
|
}),
|
|
);
|
|
ws.close();
|
|
return;
|
|
}
|
|
|
|
// Rate limit — lazily create the bucket so reconnecting clients
|
|
// start with a fresh burst allowance.
|
|
if (ws.data.rateLimiter === undefined) {
|
|
ws.data.rateLimiter = new RateLimiter();
|
|
}
|
|
if (!ws.data.rateLimiter.consume()) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: Date.now(),
|
|
type: "error",
|
|
payload: {
|
|
code: "RATE_LIMIT",
|
|
message: "rate limit exceeded",
|
|
fatal: false,
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
handleMessage(ws, msg);
|
|
},
|
|
close(ws, code, reason) {
|
|
unregisterConnection(ws);
|
|
logger.child({ clientId: ws.data.clientId }).info({ code, reason }, "ws closed");
|
|
},
|
|
},
|
|
});
|
|
|
|
logger.info({ port }, "server started");
|
|
|
|
process.on("SIGINT", () => {
|
|
logger.info("shutting down");
|
|
server.stop(true);
|
|
process.exit(0);
|
|
});
|