houserules/packages/server/src/index.ts
Joey Yakimowich-Payne 2d0b8399f8
feat(server): add reconnection with 60s grace + snapshot resume (P4.7)
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
2026-04-16 17:38:26 -06:00

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);
});