Public: anyone can read this; the crew can change it.

forked from trevato/cs

The room's two doors are tested where they are opened: POST /api/room and the socket upgrade answer to src/server.test.ts #352

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/335-the-room-s-two-doors-are-tested-where-th → main 1 commit fork

  • Merged. torque/335-the-room-s-two-doors-are-tested-where-th was merged into main as a27aef2.

Review

Sign in to review this change.
2 files changed +536 −3
src/server.test.ts +528 −2
@@ -20,6 +20,15 @@ import { parseSituation, type Situation } from "./plan/situation.ts";
2020 import { sign } from "./who.ts";
2121 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
2222 import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
23 // The rooms, for the two doors at the end of this file: what a code is, what
24 // the hub holds, and the frames that cross a socket once one is open.
25 import { isRoomCode } from "./rooms/code.ts";
26 import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
27 import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts";
28 import { applySnapshot, readDown, writeInput, INPUT_BYTES, SNAPSHOT_FRAME, type Hello } from "./rooms/wire.ts";
29 import { COMPETITIVE, SHORT } from "./client/match.ts";
30 import type { PlayedMap } from "./mapname.ts";
31 import { createWorld, NO_INPUT } from "./client/world.ts";
2332
2433 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
2534 process.env.PORT = "0";
@@ -29,8 +38,8 @@ const BUNDLES = bundlesDir();
2938 // The test is the forge: it holds the same key and signs identities with it.
3039 const KEY = "the-key-the-forge-signs-with";
3140 process.env.FORGE_APP_IDENTITY_KEY = KEY;
32 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, onError, findScripts, holdStatic } =
33 await import("./server.ts");
41 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, ROOM_LIMIT, SOCKET_PATH, hub, onError, findScripts,
42 holdStatic } = await import("./server.ts");
3443 const base = `http://${server.hostname}:${server.port}`;
3544
3645 /** The two headers the forge puts on a request it proxies for a signed-in person. */
@@ -1236,3 +1245,520 @@ test("the endpoint meta is how a page names where a plan comes from", () => {
12361245 // Escaped like everything else the shell is handed.
12371246 expect(endpoints([{ id: 'x"y', href: '/a"b' }])).toContain('id="x"y" content="/a"b"');
12381247 });
1248
1249 // --- the room's two doors ---------------------------------------------------
1250
1251 /**
1252 * `POST /api/room` and the socket upgrade are the only two endpoints here that
1253 * hold a connection open, and the only two a stranger reaches with no result to
1254 * post. The four test files under `src/rooms/` are the hub, the room and the
1255 * codec through plain objects and never see a request; what follows is the
1256 * doors themselves — the statuses, the headers, the cookie, the cut on a name
1257 * and every refusal — against the server this file has been fetching all along.
1258 *
1259 * Two habits every test below keeps, because a room is a world this process
1260 * steps 64 times a second for as long as it is held:
1261 *
1262 * - **It gives its room back.** The hub frees an empty room after `IDLE_MS`,
1263 * which is ninety seconds and outlives this suite, so a room left behind
1264 * would be stepped for the rest of the run and would push the next test
1265 * towards `ROOM_CAP`. `finish` closes the sockets and deletes the room, and
1266 * the last test here reads the hub to prove nothing was forgotten.
1267 * - **It is its own caller.** `ROOM_LIMIT` is three rooms per caller keyed by
1268 * address and session, and every test here comes from this one address — so
1269 * each takes a session cookie nobody else uses and spends its own three
1270 * tokens. Nothing below depends on where in the file it runs, the flood test
1271 * included.
1272 *
1273 * It is in this already long file rather than in a sibling of its own for one
1274 * mechanical reason: `bun test` runs every file in one process with one module
1275 * registry, so a second file that imported `./server.ts` would share *this*
1276 * file's server — and, running before it, would set `FORGE_APP_DATA` after the
1277 * bundles had been built into somewhere else. One file imports the server, and
1278 * `src/rooms/room.test.ts` already says the socket's own half is here.
1279 */
1280
1281 /** A caller nobody else here is: the session cookie derived from a test's label. */
1282 const caller = (label: string): string =>
1283 `cs_session=${new Bun.CryptoHasher("sha256").update(label).digest("hex").slice(0, 32)}`;
1284
1285 /** The room door, with the bytes a caller actually wrote. */
1286 const postRoom = (body: string, headers: Record<string, string> = {}): Promise<Response> =>
1287 fetch(base + "/api/room", { method: "POST", headers: { "content-type": "application/json", ...headers }, body });
1288
1289 /** …and the usual case: a JSON object, from a caller of this test's own. */
1290 const askRoom = (label: string, asked: unknown = {}): Promise<Response> =>
1291 postRoom(JSON.stringify(asked), { cookie: caller(label) });
1292
1293 /** What the room door answers with, once it has answered. */
1294 type Opened = { code: string; map: string; length: string };
1295
1296 const openedRoom = async (label: string, asked: unknown = {}): Promise<Opened> => {
1297 const response = await askRoom(label, asked);
1298 expect(response.status).toBe(200);
1299 return (await response.json()) as Opened;
1300 };
1301
1302 /** Poll until this is true or the deadline passes; the answer is what it was. */
1303 const until = async (ready: () => boolean, ms = 2000): Promise<boolean> => {
1304 const deadline = Date.now() + ms;
1305 while (!ready() && Date.now() < deadline) await Bun.sleep(5);
1306 return ready();
1307 };
1308
1309 /** What a page holds: the socket, every frame it was handed, and the close. */
1310 type Client = {
1311 ws: WebSocket;
1312 frames: (string | Uint8Array)[];
1313 /** The code the connection was closed with, or null while it is open. */
1314 closeCode: () => number | null;
1315 /** Closes it and waits for the socket to be shut, so no test leaks one. */
1316 close: () => Promise<void>;
1317 };
1318
1319 /** A real browser's end of a room, connected and open. */
1320 async function connect(query: string): Promise<Client> {
1321 const ws = new WebSocket(`${base.replace("http://", "ws://")}${SOCKET_PATH}?${query}`);
1322 ws.binaryType = "arraybuffer";
1323 const frames: (string | Uint8Array)[] = [];
1324 let closed: number | null = null;
1325 ws.addEventListener("message", (event: MessageEvent) => {
1326 frames.push(typeof event.data === "string" ? event.data : new Uint8Array(event.data as ArrayBuffer));
1327 });
1328 ws.addEventListener("close", (event: CloseEvent) => { closed = event.code; });
1329 const client: Client = {
1330 ws, frames,
1331 closeCode: () => closed,
1332 close: async () => {
1333 ws.close();
1334 await until(() => ws.readyState === WebSocket.CLOSED);
1335 },
1336 };
1337 expect(await until(() => ws.readyState === WebSocket.OPEN || closed !== null)).toBe(true);
1338 expect(ws.readyState).toBe(WebSocket.OPEN);
1339 return client;
1340 }
1341
1342 /** The end of a test: close what it opened, and hand the room back to the hub. */
1343 const finish = async (code: string, ...clients: Client[]): Promise<void> => {
1344 for (const client of clients) await client.close();
1345 hub.rooms.delete(code);
1346 };
1347
1348 /**
1349 * The 101 itself, read off the wire. A `WebSocket` hands a page the socket and
1350 * never the response it came out of, so the handshake is written here by hand
1351 * and the status line and headers are read back before the connection is
1352 * dropped: it is the only way to see what a browser's upgrade was answered
1353 * with. The bytes after the blank line are the room's first frames and are not
1354 * read — this is about the head.
1355 */
1356 async function handshake(query: string, extra: Record<string, string> = {}): Promise<{
1357 status: number; headers: Headers;
1358 }> {
1359 const key = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
1360 const request = [
1361 `GET ${SOCKET_PATH}?${query} HTTP/1.1`,
1362 `Host: ${new URL(base).host}`,
1363 "Upgrade: websocket",
1364 "Connection: Upgrade",
1365 `Sec-WebSocket-Key: ${key}`,
1366 "Sec-WebSocket-Version: 13",
1367 ...Object.entries(extra).map(([name, value]) => `${name}: ${value}`),
1368 ].join("\r\n") + "\r\n\r\n";
1369
1370 let head = "";
1371 const where = new URL(base);
1372 const socket = await Bun.connect({
1373 hostname: where.hostname,
1374 port: Number(where.port),
1375 socket: {
1376 open(connection) { connection.write(request); },
1377 data(_connection, chunk) { head += chunk.toString("latin1"); },
1378 },
1379 });
1380 expect(await until(() => head.includes("\r\n\r\n"))).toBe(true);
1381 socket.end();
1382
1383 const [status, ...rest] = head.slice(0, head.indexOf("\r\n\r\n")).split("\r\n");
1384 const headers = new Headers();
1385 for (const line of rest) {
1386 const at = line.indexOf(":");
1387 if (at > 0) headers.append(line.slice(0, at).trim(), line.slice(at + 1).trim());
1388 }
1389 return { status: Number((status ?? "").split(" ")[1]), headers };
1390 }
1391
1392 test("`POST /api/room` answers a code as no-store JSON, on the floor, with a session", async () => {
1393 const response = await askRoom("opens one");
1394 expect(response.status).toBe(200);
1395 expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8");
1396 expect(response.headers.get("cache-control")).toBe("no-store");
1397 // The floor a plan comes back on, header for header: this is data and not a
1398 // document, so it carries the policy with no style hash in it.
1399 const plan = await askPlan(JSON.stringify(situation()));
1400 expect(response.headers.get("content-security-policy")).toBe(plan.headers.get("content-security-policy"));
1401 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
1402 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
1403 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
1404 expect(response.headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()");
1405
1406 // The session it arrived with, back in the cookie every page here sets.
1407 const cookie = response.headers.get("set-cookie") ?? "";
1408 expect(cookie).toContain(caller("opens one"));
1409 expect(cookie).toContain("HttpOnly");
1410 expect(cookie).toContain("SameSite=Lax");
1411 expect(cookie).toContain("Path=/");
1412
1413 // And the answer is a code, a map and a length, and nothing else at all.
1414 const room = (await response.json()) as Opened;
1415 expect(isRoomCode(room.code)).toBe(true);
1416 expect(room).toEqual({ code: room.code, map: "de_forge", length: "short" });
1417 // The code names a room this process is holding, with nobody in it yet.
1418 expect(hub.find(room.code)?.map).toBe("de_forge");
1419 expect(hub.find(room.code)?.members.size).toBe(0);
1420 await finish(room.code);
1421 });
1422
1423 test("a browser with no session is given one on the way into its first room", async () => {
1424 // The one test here with no cookie of its own, because that is the thing it
1425 // is about: the caller keyed `room:<address>|-` is this one and nobody else.
1426 const response = await postRoom("{}");
1427 expect(response.status).toBe(200);
1428 expect(response.headers.get("set-cookie")).toMatch(/^cs_session=[0-9a-f]{32}; Path=\/; HttpOnly; SameSite=Lax/);
1429 await finish(((await response.json()) as Opened).code);
1430 });
1431
1432 test("the map and the length are settled at the door: an unknown map is de_forge, and only `long` is long", async () => {
1433 const cases: [unknown, PlayedMap, "short" | "long"][] = [
1434 [{}, "de_forge", "short"],
1435 [{ map: "de_dust2", length: "epic" }, "de_forge", "short"],
1436 [{ map: 7, length: 1 }, "de_forge", "short"],
1437 [{ map: null, length: "Long" }, "de_forge", "short"],
1438 [{ map: "<script>alert(1)</script>", length: ["long"] }, "de_forge", "short"],
1439 [{ map: "generated" }, "de_forge", "short"],
1440 [{ map: "de_forge", length: "long" }, "de_forge", "long"],
1441 [{ map: "gen_14u5j6g", length: "long" }, "gen_14u5j6g", "long"],
1442 ];
1443 for (const [asked, map, length] of cases) {
1444 const room = await openedRoom(`map ${JSON.stringify(asked)}`, asked);
1445 expect([room.map, room.length]).toEqual([map, length]);
1446 // The room this process is holding is the one the answer described, and
1447 // the rules under it are the long match's only when the answer said long.
1448 const held = hub.find(room.code);
1449 expect(held?.map).toBe(map);
1450 expect(held?.length).toBe(length);
1451 expect(held?.rules).toBe(length === "long" ? COMPETITIVE : SHORT);
1452 await finish(room.code);
1453 }
1454 // A body that is not an object at all is not an error: it asked for nothing,
1455 // so it gets what asking for nothing gets.
1456 for (const body of ["[]", "null", '"de_dust2"', "7", "{}"]) {
1457 const response = await postRoom(body, { cookie: caller(`not an object ${body}`) });
1458 expect(response.status).toBe(200);
1459 const room = (await response.json()) as Opened;
1460 expect([room.map, room.length]).toEqual(["de_forge", "short"]);
1461 await finish(room.code);
1462 }
1463 });
1464
1465 test("a room is opened by this page and by nobody else, and never by a read", async () => {
1466 const before = hub.rooms.size;
1467 const elsewhere: Record<string, string>[] = [
1468 { "sec-fetch-site": "cross-site" },
1469 { "sec-fetch-site": "same-site" },
1470 { origin: "https://evil.example" },
1471 ];
1472 for (const headers of elsewhere) {
1473 const response = await postRoom(JSON.stringify({ map: "de_forge" }), { ...headers, cookie: caller("cross-site") });
1474 expect(response.status).toBe(403);
1475 expect(await response.text()).toBe("forbidden");
1476 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
1477 expect(response.headers.get("set-cookie")).toBeNull();
1478 }
1479 // And it is not a read: nothing opens a room for a GET or a HEAD.
1480 expect((await fetch(base + "/api/room")).status).toBe(404);
1481 expect((await server.fetch(new Request(base + "/api/room", { method: "HEAD" }))).status).toBe(404);
1482 // Not one of them left a world behind for this process to step.
1483 expect(hub.rooms.size).toBe(before);
1484 });
1485
1486 test("a body that is not a room to open is refused by the bytes read, and opens nothing", async () => {
1487 const before = hub.rooms.size;
1488 const bad = await postRoom("{", { cookie: caller("bad json") });
1489 expect(bad.status).toBe(400);
1490 expect(await bad.text()).toBe("bad json");
1491 // A kilobyte is the cap, counted as it arrives rather than believed off a
1492 // header: this body declares nothing and is cut off anyway.
1493 const big = await postRoom(`{"map":"de_forge","pad":"${"x".repeat(4096)}"}`, { cookie: caller("too large") });
1494 expect(big.status).toBe(413);
1495 expect(await big.text()).toBe("too large");
1496 for (const response of [bad, big])
1497 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
1498 expect(hub.rooms.size).toBe(before);
1499 });
1500
1501 test("one caller cannot open rooms as fast as it likes", async () => {
1502 // This spends one caller's bucket to the last token. Every other test here
1503 // has a caller of its own, so this one poisons nothing and needs no place in
1504 // the file — which is why it is here and not last.
1505 const mine = { cookie: caller("flooder") };
1506 const codes: string[] = [];
1507 let refused: Response | undefined;
1508 for (let i = 0; i <= ROOM_LIMIT.burst; i++) {
1509 const response = await postRoom("{}", mine);
1510 if (response.status === 429) { refused = response; break; }
1511 expect(response.status).toBe(200);
1512 codes.push(((await response.json()) as Opened).code);
1513 }
1514 expect(codes.length).toBe(ROOM_LIMIT.burst);
1515 expect(refused?.status).toBe(429);
1516 expect(await refused?.text()).toBe("slow down");
1517 expect(refused?.headers.get("retry-after")).toBe(String(ROOM_LIMIT.per / 1000));
1518 expect(refused?.headers.get("content-security-policy")).toContain("default-src 'self'");
1519 // Somebody else's bucket is their own: one flood does not close the door.
1520 const other = await postRoom("{}", { cookie: caller("bystander") });
1521 expect(other.status).toBe(200);
1522 codes.push(((await other.json()) as Opened).code);
1523 for (const code of codes) await finish(code);
1524 });
1525
1526 test("past ROOM_CAP the answer is 503 and the sentence names the cap", async () => {
1527 // Nineteen rooms through the hub and only the twentieth through the route:
1528 // nineteen POSTs would want seven callers to get past ROOM_LIMIT, and the
1529 // room this test is about is the one the route refuses rather than the ones
1530 // underneath it. It is cheap either way — nineteen de_forge rooms are 6.5 ms
1531 // on this tree — so what picked the hub was the limiter, not the clock.
1532 const filled: string[] = [];
1533 while (hub.rooms.size < ROOM_CAP - 1) {
1534 const room = hub.open({ map: "de_forge", seed: hub.rooms.size, length: "short", now: Date.now() });
1535 expect(room).not.toBeNull();
1536 filled.push((room as { code: string }).code);
1537 }
1538 try {
1539 const last = await postRoom("{}", { cookie: caller("cap") });
1540 expect(last.status).toBe(200);
1541 filled.push(((await last.json()) as Opened).code);
1542 expect(hub.rooms.size).toBe(ROOM_CAP);
1543
1544 const refused = await postRoom("{}", { cookie: caller("cap") });
1545 expect(refused.status).toBe(503);
1546 expect(await refused.text()).toBe(`every room is taken — this deployment holds ${ROOM_CAP}`);
1547 expect(refused.headers.get("content-type")).toBe("text/plain; charset=utf-8");
1548 expect(refused.headers.get("content-security-policy")).toContain("default-src 'self'");
1549 // A refusal is not a room: the cap is still the cap.
1550 expect(hub.rooms.size).toBe(ROOM_CAP);
1551 } finally {
1552 for (const code of filled) hub.rooms.delete(code);
1553 }
1554 });
1555
1556 test("the upgrade refuses at the door: no code is 400, an unknown code is 404, another site is 403", async () => {
1557 const room = await openedRoom("upgrade refusals");
1558 try {
1559 // Nothing asked for, and things asked for that are not codes: the wrong
1560 // length, a space, and the letters the alphabet leaves out (O, 0, I, L, 1, S).
1561 for (const query of ["", "?room=", "?room=ABCDE", "?room=ABCDEFG", `?room=${encodeURIComponent("ABC DEF")}`,
1562 "?room=O0IL1S", "?room=../../etc", `?room=${encodeURIComponent("<script>")}`]) {
1563 const response = await fetch(base + SOCKET_PATH + query);
1564 expect(response.status).toBe(400);
1565 expect(await response.text()).toBe("not a room code");
1566 expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8");
1567 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
1568 }
1569
1570 // A code shaped like one, for a room nobody is holding. The one in a
1571 // billion where the hub drew it a moment ago is made impossible here.
1572 hub.rooms.delete("ABCDEF");
1573 const gone = await fetch(`${base}${SOCKET_PATH}?room=ABCDEF`);
1574 expect(gone.status).toBe(404);
1575 expect(await gone.text()).toBe("no room with that code — it may have been idle too long");
1576 expect(gone.headers.get("content-type")).toBe("text/plain; charset=utf-8");
1577
1578 // And another site cannot upgrade, with a code that is real and current:
1579 // the refusal comes before the room is even looked up.
1580 const elsewhere: Record<string, string>[] = [{ "sec-fetch-site": "cross-site" }, { origin: "https://evil.example" }];
1581 for (const headers of elsewhere) {
1582 const response = await fetch(`${base}${SOCKET_PATH}?room=${room.code}`, { headers });
1583 expect(response.status).toBe(403);
1584 expect(await response.text()).toBe("forbidden");
1585 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
1586 }
1587 // Not one of those refusals seated anybody.
1588 expect(hub.find(room.code)?.members.size).toBe(0);
1589 } finally {
1590 await finish(room.code);
1591 }
1592 });
1593
1594 test("a plain `GET /ws` with no upgrade header is the app's own 404 page, not an error", async () => {
1595 const room = await openedRoom("plain get");
1596 try {
1597 // Lower case on purpose: the code is upper-cased at the door, so this one
1598 // resolves — a code that did not would be the plain-text 404 above, and
1599 // what comes back instead is the document every wrong path gets.
1600 const miss = await fetch(`${base}${SOCKET_PATH}?room=${room.code.toLowerCase()}`);
1601 expect(miss.status).toBe(404);
1602 expect(miss.headers.get("content-type")).toBe("text/html; charset=utf-8");
1603 const body = await miss.text();
1604 expect(body.startsWith("<!doctype html>")).toBe(true);
1605 expect(body).toContain("<h1>not found</h1>");
1606 expect(floorOf(miss)).toEqual(floorOf(await fetch(base + "/nope")));
1607
1608 // HEAD is that 404 with nothing to read, like every other path here. It
1609 // goes over the wire rather than through `server.fetch`, because this route
1610 // asks the listener to upgrade and `server.fetch` hands it no listener.
1611 const head = await fetch(`${base}${SOCKET_PATH}?room=${room.code}`, { method: "HEAD" });
1612 expect(head.status).toBe(404);
1613 expect(await head.text()).toBe("");
1614
1615 // Reading a room is not joining one.
1616 expect(hub.find(room.code)?.members.size).toBe(0);
1617 } finally {
1618 await finish(room.code);
1619 }
1620 });
1621
1622 test("the upgrade is a 101 carrying the session cookie and the security floor", async () => {
1623 const room = await openedRoom("handshake");
1624 try {
1625 const session = "d".repeat(32);
1626 const { status, headers } = await handshake(`room=${room.code}&name=handshake`, {
1627 Cookie: `cs_session=${session}`,
1628 });
1629 expect(status).toBe(101);
1630 expect(headers.get("upgrade")?.toLowerCase()).toBe("websocket");
1631 expect(headers.get("sec-websocket-accept")).not.toBeNull();
1632 // The session it arrived with, not a new one: a browser that has a cookie
1633 // keeps it, and the cookie is the same one every page here sets.
1634 expect(headers.get("set-cookie")).toContain(`cs_session=${session}`);
1635 expect(headers.get("set-cookie")).toContain("HttpOnly");
1636 expect(headers.get("set-cookie")).toContain("SameSite=Lax");
1637 // The floor rides on the 101 like it does on every other answer.
1638 expect(headers.get("content-security-policy")).toContain("default-src 'self'");
1639 expect(headers.get("x-content-type-options")).toBe("nosniff");
1640 expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
1641 expect(headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()");
1642
1643 // A browser with no cookie is given one on the 101 as well.
1644 const fresh = await handshake(`room=${room.code}&name=handshake`);
1645 expect(fresh.status).toBe(101);
1646 expect(fresh.headers.get("set-cookie")).toMatch(/cs_session=[0-9a-f]{32}; Path=\/; HttpOnly; SameSite=Lax/);
1647
1648 // Both connections were dropped by `handshake`; the seats go with them.
1649 expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true);
1650 } finally {
1651 await finish(room.code);
1652 }
1653 });
1654
1655 test("a real socket into a real room is handed hello and then the round, sixteen times a second", async () => {
1656 const room = await openedRoom("hello", { map: "de_forge" });
1657 const client = await connect(`room=${room.code}&name=torque`);
1658 try {
1659 expect(await until(() => client.frames.some((frame) => typeof frame !== "string"))).toBe(true);
1660
1661 // The first thing down the wire is the hello, and it is about this room.
1662 const hello = readDown(client.frames[0] as string) as Hello;
1663 expect(hello?.k).toBe("hello");
1664 expect(hello.code).toBe(room.code);
1665 expect(hello.map).toBe("de_forge");
1666 expect(hello.rules).toBe("short");
1667 expect(hello.snapshotHz).toBe(SNAPSHOT_HZ);
1668 expect(hello.roster.length).toBe(TEAM_SIZE * 2);
1669 expect(hello.roster[hello.seat]?.name).toBe("torque");
1670 expect(hello.roster[hello.seat]?.bot).toBe(false);
1671 // The room agrees: one member, holding that seat.
1672 const held = hub.find(room.code);
1673 expect(held?.members.size).toBe(1);
1674 expect([...(held?.members.values() ?? [])][0]?.seat).toBe(hello.seat);
1675
1676 // Then the round itself, as bytes a page reads: the same snapshot the
1677 // shooter's own client applies, against a world carved from the two
1678 // numbers the hello handed over.
1679 const snapshot = client.frames.find((frame) => typeof frame !== "string") as Uint8Array;
1680 expect(snapshot[0]).toBe(SNAPSHOT_FRAME);
1681 const watching = createWorld(levelOf("de_forge"), SHORT, "watching", "ct", TEAM_SIZE, hello.seed);
1682 const read = applySnapshot(watching, snapshot, hello.seat);
1683 expect(read).not.toBeNull();
1684 expect(read?.tick).toBeGreaterThan(0);
1685 } finally {
1686 await client.close();
1687 // The close is what frees the seat, and the bot takes its body back.
1688 expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true);
1689 expect(hub.find(room.code)?.world.players.every((p) => p.bot)).toBe(true);
1690 hub.rooms.delete(room.code);
1691 }
1692 });
1693
1694 test("a name longer than 24 characters is cut at the door, before the room ever sees it", async () => {
1695 // The door cuts at 24; the room then strips what is not printable and cuts at
1696 // 20 (`uniqueName`). A name whose first ten characters are control codes
1697 // tells the two apart: cut at 24 first and fourteen letters survive, cut only
1698 // at 20 in the room and twenty would.
1699 const wanted = "\u0001".repeat(10) + "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1700 const room = await openedRoom("long name");
1701 const client = await connect(`room=${room.code}&name=${encodeURIComponent(wanted)}`);
1702 try {
1703 expect(await until(() => client.frames.length > 0)).toBe(true);
1704 const hello = readDown(client.frames[0] as string) as Hello;
1705 expect(hello.roster[hello.seat]?.name).toBe("ABCDEFGHIJKLMN");
1706 } finally {
1707 await finish(room.code, client);
1708 }
1709 });
1710
1711 test("a text frame on the socket is dropped, and the same bytes as binary are an input", async () => {
1712 const room = await openedRoom("text frame");
1713 const client = await connect(`room=${room.code}&name=typist`);
1714 try {
1715 expect(await until(() => client.frames.length > 0)).toBe(true);
1716 const member = [...(hub.find(room.code)?.members.values() ?? [])][0];
1717 expect(member).toBeDefined();
1718
1719 // Fourteen characters, the exact width of an input frame, as text. Text is
1720 // not part of this protocol in this direction, so it is dropped before the
1721 // room: not driven, and not even counted as a frame the room refused.
1722 client.ws.send("x".repeat(INPUT_BYTES));
1723 // The binary frame behind it is the clock: once its tick has landed, the
1724 // text frame in front of it has been through the same handler.
1725 client.ws.send(new Uint8Array(writeInput({
1726 tick: 5, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null,
1727 })));
1728 expect(await until(() => member?.lastTick === 5)).toBe(true);
1729 expect(member?.dropped).toBe(0);
1730 expect(client.closeCode()).toBeNull();
1731 } finally {
1732 await finish(room.code, client);
1733 }
1734 });
1735
1736 test("a binary frame over MESSAGE_MAX closes the connection before this process holds it", async () => {
1737 const room = await openedRoom("huge frame");
1738 const client = await connect(`room=${room.code}&name=shouter`);
1739 try {
1740 expect(await until(() => client.frames.length > 0)).toBe(true);
1741 const member = [...(hub.find(room.code)?.members.values() ?? [])][0];
1742
1743 client.ws.send(new Uint8Array(MESSAGE_MAX + 1));
1744 expect(await until(() => client.closeCode() !== null)).toBe(true);
1745 // Bun refuses a frame past `maxPayloadLength` itself, so this close is the
1746 // runtime's and never the hub's own 4001: the bytes were never handed to a
1747 // room, which is why there is nothing here to parse and nothing counted.
1748 expect(client.closeCode()).not.toBe(CLOSE_TOO_BIG);
1749 expect(member?.dropped).toBe(0);
1750 // And the seat goes back to the bot that had it.
1751 expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true);
1752 expect(hub.find(room.code)?.world.players.every((p) => p.bot)).toBe(true);
1753 } finally {
1754 await finish(room.code, client);
1755 }
1756 });
1757
1758 test("every room these tests opened was given back, and every socket closed", async () => {
1759 // The invariant the whole section is written to: nothing above leaves a world
1760 // being stepped, and no seat is held by a connection that is gone. A failure
1761 // here names a test above that forgot its `finish`.
1762 expect(await until(() => hub.members() === 0)).toBe(true);
1763 expect([...hub.rooms.keys()]).toEqual([]);
1764 });
src/server.ts +8 −1
@@ -302,9 +302,16 @@ const roomLimit = createLimiter(ROOM_LIMIT);
302302 * session are two members, and a reload is a new one whose predecessor is taken
303303 * out of the room by its own close. So a seat is never held by a tab that is
304304 * gone, and a session cookie is not a claim on a seat.
305 *
306 * The hub is exported for one reason, and it is the tests': `src/server.test.ts`
307 * drives the two doors below over a real socket, and a room opened by a test is
308 * a world this process steps until the hub's sweep takes it ninety seconds
309 * later. So a test gives its room back when it is done — `hub.rooms.delete` —
310 * and reads the member it just made to check what the room was told. Nothing in
311 * this app imports it; a room is still opened only through the route below.
305312 */
306313 const sockets = new Map<string, Bun.ServerWebSocket<Joined>>();
307 const hub = createHub({
314 export const hub = createHub({
308315 deliver(id, frame) {
309316 sockets.get(id)?.send(frame);
310317 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs