Public: anyone can read this; the crew can change it.
A guest gets a new seat every time they open a socket: `seatOf` still mints a random key #65
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/59-a-guest-gets-a-new-seat-every-time-they → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +587
−80
README.md +33 −15
| @@ -51,9 +51,11 @@ simulation yet. There is no world tick and no score behind them; the | ||
| 51 | 51 | regions are shells with honest empty states, and the round loop, the canvas |
| 52 | 52 | and the ladder fill them in the issues that follow. `POST /mcp` answers and |
| 53 | 53 | lists no tool. `GET /ws` upgrades and enforces every rule a connection has |
| 54 | — origin, identity, one seat per person, a ration, backpressure — but | |
| 54 | — origin, identity, one seat per player key, a ration, backpressure — but | |
| 55 | 55 | nothing publishes frames on it yet, so a socket that opens stays quiet |
| 56 | until the round drives it. | |
| 56 | until the round drives it. No door sets the guest cookie yet either, so | |
| 57 | today every browser reaching `/ws` is a spectator and is told so; the page | |
| 58 | that calls `session` and hands out a key lands with the swimmer's view. | |
| 57 | 59 | |
| 58 | 60 | The sea, on the other hand, is real arithmetic that nothing is plugged into |
| 59 | 61 | yet. `src/world.ts` advances one tick — motes, momentum, the current field, |
| @@ -78,7 +80,7 @@ issue after this one. | ||
| 78 | 80 | The only terrain is the wake other players leave. Your thruster is feeble; |
| 79 | 81 | the currents other swimmers deposit are not, and the bloom only reaches its |
| 80 | 82 | gate when several people braid one lane. [](docs/wake.md) is |
| 81 | the design record — the mechanic, the starting numbers and the seven | |
| 83 | the design record — the mechanic, the starting numbers and the eight | |
| 82 | 84 | invariants every build issue is measured against. It is not decoration: the |
| 83 | 85 | numbers the page states live in `src/concept.ts` and `bun test` fails if |
| 84 | 86 | they and the record drift apart. A seat in the sea belongs to a player key |
| @@ -99,7 +101,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 99 | 101 | | `src/actions.ts` | every operation, and who may: players, rounds, the ladder | |
| 100 | 102 | | `src/world.ts` | the simulation: motes, momentum and the current field | |
| 101 | 103 | | `src/round.ts` | the round: its clock, its end, its seats, and effects as data | |
| 102 | | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized | | |
| 104 | | `src/protocol.ts` | the wire: a frame or a sentence down, a steer up, quantised and sized | | |
| 103 | 105 | | `src/frame.ts` | one world and one clock in, one frame out, pure and measured | |
| 104 | 106 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 105 | 107 | | `src/session.ts` | the guest key in a cookie, and the player id it derives | |
| @@ -159,10 +161,10 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 159 | 161 | |
| 160 | 162 | ### Playing as a guest |
| 161 | 163 | |
| 162 | *Written, not yet wired: `src/session.ts` and the player actions are in the | |
| 163 | app, with their tests, but no door issues the cookie yet — that lands with | |
| 164 | the socket that admits a swimmer to the world. What follows is how guests | |
| 165 | work the moment one does.* | |
| 164 | *Half wired: the socket reads the cookie and keys a seat by it, so a guest | |
| 165 | who has one is the same swimmer in every tab — but no door issues one yet, | |
| 166 | and a browser carrying no cookie watches. What follows is how guests work | |
| 167 | the moment a door hands one out.* | |
| 166 | 168 | |
| 167 | 169 | Nobody has to sign in to swim. A visitor the forge cannot name is given a |
| 168 | 170 | **guest key** — 32 random bytes in an `HttpOnly; SameSite=Lax` cookie — and |
| @@ -233,13 +235,29 @@ the connection: no message ever names its own sender. Every message is | ||
| 233 | 235 | validated by `parseSteer` and by nothing else, and a message that fails |
| 234 | 236 | closes the socket with `1008`. Steering is rationed per socket — 20 Hz |
| 235 | 237 | passes forever, a burst over the bucket is dropped where it lands rather |
| 236 | than queued, and a client that keeps flooding is closed. **A seat is a | |
| 237 | person, not a tab:** a second socket on the same player key closes the | |
| 238 | first with `4000`, so one person is one mote however many tabs they open | |
| 239 | and nobody can hold the roster alone. A socket is pinged every 25 seconds | |
| 240 | and closed with `4001` after 90 seconds of silence, and a client whose send | |
| 241 | buffer is backed up is skipped for that frame rather than waited on, so a | |
| 242 | slow phone degrades alone. | |
| 238 | than queued, and a client that keeps flooding is closed. **A seat is keyed | |
| 239 | by the player key and by nothing the connection invents** — the forge id | |
| 240 | for a signed-in person, the guest id derived from the `HttpOnly` cookie for | |
| 241 | everybody else (`playerKey` in `src/session.ts`), never an address and | |
| 242 | never a key a query string, a header or a message offered. **A seat is a | |
| 243 | person, not a tab:** every socket under one key is another window on the | |
| 244 | same seat and steers the same mote, so the sixth tab is refused no more | |
| 245 | than the first and none of them closes another; the roster shows one | |
| 246 | swimmer because there is one. Eight windows per key is the ration, and a | |
| 247 | connection past it is not refused either — it watches. A request carrying | |
| 248 | no session has no key, so it gets no seat: it is admitted as a | |
| 249 | **spectator**, told so in the record's own sentence (`SPECTATOR` in | |
| 250 | `src/concept.ts`) as its first message, and every frame still goes down it | |
| 251 | while everything it steers is dropped. The upgrade has no response to set a | |
| 252 | cookie on, so a first visit is a page load and then a socket — one round | |
| 253 | trip — and nothing is ever minted at the upgrade to avoid it. The one | |
| 254 | socket ever closed for something its person did not do is the guest window | |
| 255 | still open when that browser signs in: signing in is a change of key, so | |
| 256 | the guest seat is released with `4000` and the page comes back as who it is | |
| 257 | now. A socket is pinged every 25 seconds and closed with `4001` after | |
| 258 | 90 seconds of silence, and a client whose send buffer is backed up is | |
| 259 | skipped for that frame rather than waited on, so a slow phone degrades | |
| 260 | alone. | |
| 243 | 261 | |
| 244 | 262 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 245 | 263 | runs (the app is the login name, the forge the host). It is `src/tty.ts` on |
src/protocol.test.ts +43
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { SPECTATOR } from "./concept.ts"; | |
| 2 | 3 | import { |
| 3 | 4 | BLOOM_STRIDE, |
| 4 | 5 | decodeFrame, |
| 6 | decodeMessage, | |
| 5 | 7 | encodeFrame, |
| 8 | encodeSentence, | |
| 6 | 9 | FIELD_LENGTH, |
| 7 | 10 | FIELD_MAX, |
| 8 | 11 | FRAME_MAX_BYTES, |
| @@ -14,6 +17,7 @@ import { | ||
| 14 | 17 | PROTOCOL_VERSION, |
| 15 | 18 | type ProtocolFailure, |
| 16 | 19 | ROSTER_MAX, |
| 20 | SENTENCE_MAX_BYTES, | |
| 17 | 21 | SEQ_MAX, |
| 18 | 22 | STEER_MAX_BYTES, |
| 19 | 23 | THRUST_SCALE, |
| @@ -257,3 +261,42 @@ test("a steer at the size cap is still accepted", () => { | ||
| 257 | 261 | expect(padded.length).toBeLessThanOrEqual(STEER_MAX_BYTES); |
| 258 | 262 | expect(parseSteer(padded).ok).toBe(true); |
| 259 | 263 | }); |
| 264 | ||
| 265 | // ------------------------------------------------- two shapes going down | |
| 266 | ||
| 267 | test("bytes are a frame, text is a sentence, and one reader tells them apart", () => { | |
| 268 | const frame = encodeFrame(sampleFrame(3)); | |
| 269 | const asFrame = decodeMessage(frame); | |
| 270 | expect(asFrame.ok && asFrame.value.kind).toBe("frame"); | |
| 271 | if (asFrame.ok && asFrame.value.kind === "frame") | |
| 272 | expect(asFrame.value.frame.motes.length).toBe(3 * MOTE_STRIDE); | |
| 273 | ||
| 274 | // A sentence comes back as itself: not parsed, not unwrapped, not JSON. | |
| 275 | const said = decodeMessage(SPECTATOR); | |
| 276 | expect(said.ok && said.value.kind).toBe("sentence"); | |
| 277 | if (said.ok && said.value.kind === "sentence") | |
| 278 | expect(said.value.sentence).toBe(SPECTATOR); | |
| 279 | ||
| 280 | // A client that hands a sentence to `decodeFrame` gets `foreign` for | |
| 281 | // something that was never a frame — which is why this reader exists. | |
| 282 | expect(decodeFrame(SPECTATOR).ok).toBe(false); | |
| 283 | // And a payload that is neither is still a named failure, never a throw. | |
| 284 | const rubbish = decodeMessage(new Uint8Array([1, 2, 3])); | |
| 285 | expect(rubbish.ok).toBe(false); | |
| 286 | if (!rubbish.ok) expect(rubbish.error).toBe("truncated"); | |
| 287 | }); | |
| 288 | ||
| 289 | test("a sentence is capped, and the longest one the app says fits", () => { | |
| 290 | expect(encodeSentence(SPECTATOR)).toBe(SPECTATOR); | |
| 291 | expect(new TextEncoder().encode(SPECTATOR).length).toBeLessThanOrEqual( | |
| 292 | SENTENCE_MAX_BYTES, | |
| 293 | ); | |
| 294 | ||
| 295 | // A wall of words is the sender's bug going out and a named failure | |
| 296 | // coming in: neither ever reaches a page. | |
| 297 | const wall = "a".repeat(SENTENCE_MAX_BYTES + 1); | |
| 298 | expect(() => encodeSentence(wall)).toThrow(RangeError); | |
| 299 | const read = decodeMessage(wall); | |
| 300 | expect(read.ok).toBe(false); | |
| 301 | if (!read.ok) expect(read.error).toBe("too_large"); | |
| 302 | }); |
src/protocol.ts +58 −4
| @@ -1,8 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | * The wire, written once. A frame goes down (server → everyone, 10 Hz) and a | |
| 3 | * steer comes up (one player → server); this module is the only place either | |
| 4 | * shape is defined, so the round, the socket, the canvas, the terminal and | |
| 5 | * the JSON door all read the same bytes instead of each inventing them. | |
| 2 | * The wire, written once. A frame goes down (server → everyone, 10 Hz), a | |
| 3 | * sentence goes down when the app has something to say to the person at the | |
| 4 | * socket, and a steer comes up (one player → server); this module is the | |
| 5 | * only place any of the three shapes is defined, so the round, the socket, | |
| 6 | * the canvas, the terminal and the JSON door all read the same bytes instead | |
| 7 | * of each inventing them. | |
| 8 | * | |
| 9 | * Downward there are two shapes and no third, told apart by their type and | |
| 10 | * nothing else: **bytes are a frame, text is a sentence**. `decodeMessage` | |
| 11 | * is the one reader that says so, so a client never hands a sentence to | |
| 12 | * `decodeFrame` and gets `foreign` back for something that was never a frame. | |
| 6 | 13 | * |
| 7 | 14 | * Plain data in, plain data out: nothing here imports the world, the |
| 8 | 15 | * database, the bus or the HTTP floor — only the roster cap, from the design |
| @@ -67,6 +74,12 @@ export const FRAME_HEADER_BYTES = 28; | ||
| 67 | 74 | export const FRAME_MAX_BYTES = 4096; |
| 68 | 75 | /** A steer is a handful of numbers; anything bigger is refused unread. */ |
| 69 | 76 | export const STEER_MAX_BYTES = 128; |
| 77 | /** | |
| 78 | * A sentence is one short paragraph for a person to read — the spectator's | |
| 79 | * answer in `src/concept.ts` is the longest there is. The cap is the wire's, | |
| 80 | * not the reader's: nothing large is ever a sentence. | |
| 81 | */ | |
| 82 | export const SENTENCE_MAX_BYTES = 512; | |
| 70 | 83 | /** A steer's sequence number is a non-negative int32. */ |
| 71 | 84 | export const SEQ_MAX = 2_147_483_647; |
| 72 | 85 | |
| @@ -91,6 +104,15 @@ export interface DecodedFrame extends Frame { | ||
| 91 | 104 | readonly field: number[]; |
| 92 | 105 | } |
| 93 | 106 | |
| 107 | /** | |
| 108 | * One message on its way down: a frame, or a sentence for the person at the | |
| 109 | * socket. Two shapes and no third — a client that reads this reads | |
| 110 | * everything the server ever sends. | |
| 111 | */ | |
| 112 | export type Message = | |
| 113 | | { readonly kind: "frame"; readonly frame: DecodedFrame } | |
| 114 | | { readonly kind: "sentence"; readonly sentence: string }; | |
| 115 | ||
| 94 | 116 | /** One player's intent: a direction in the unit disc and the count they sent it under. */ |
| 95 | 117 | export interface Steer { |
| 96 | 118 | readonly x: number; |
| @@ -271,6 +293,38 @@ export function decodeFrame(payload: unknown): Parsed<DecodedFrame> { | ||
| 271 | 293 | }); |
| 272 | 294 | } |
| 273 | 295 | |
| 296 | /** | |
| 297 | * A sentence as it goes on the wire: the words themselves, as text, because | |
| 298 | * a frame is bytes and text is therefore never mistaken for one. Nothing is | |
| 299 | * wrapped around it — a person reads it, and `decodeMessage` hands it back | |
| 300 | * unchanged. A sentence over the cap is the caller's bug, like a frame of | |
| 301 | * the wrong shape, and throws rather than shipping a truncated one. | |
| 302 | */ | |
| 303 | export function encodeSentence(sentence: string): string { | |
| 304 | if (byteLength(sentence) > SENTENCE_MAX_BYTES) | |
| 305 | throw new RangeError(`a sentence is at most ${SENTENCE_MAX_BYTES} bytes`); | |
| 306 | return sentence; | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * One message off the socket, whichever shape it is. Never throws: bytes go | |
| 311 | * to `decodeFrame` and come back as a frame or its named failure, text is a | |
| 312 | * sentence, and a sentence past the cap is `too_large` rather than a wall of | |
| 313 | * words on a page. | |
| 314 | */ | |
| 315 | export function decodeMessage(payload: unknown): Parsed<Message> { | |
| 316 | if (typeof payload === "string") { | |
| 317 | if (byteLength(payload) > SENTENCE_MAX_BYTES) | |
| 318 | return fail( | |
| 319 | "too_large", | |
| 320 | `A sentence is at most ${SENTENCE_MAX_BYTES} bytes.`, | |
| 321 | ); | |
| 322 | return ok({ kind: "sentence", sentence: payload }); | |
| 323 | } | |
| 324 | const frame = decodeFrame(payload); | |
| 325 | return frame.ok ? ok({ kind: "frame", frame: frame.value }) : frame; | |
| 326 | } | |
| 327 | ||
| 274 | 328 | /** |
| 275 | 329 | * The only place steering is validated. Takes what the socket actually hands |
| 276 | 330 | * over — a string, bytes, or an already-parsed object — and returns either a |
src/socket.test.ts +265 −17
| @@ -2,10 +2,12 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { INVARIANTS, SPECTATOR } from "./concept.ts"; | |
| 5 | 6 | import { publicOrigin } from "./env.ts"; |
| 6 | 7 | import { SECURITY } from "./http.ts"; |
| 7 | 8 | import { STEER_MAX_BYTES } from "./protocol.ts"; |
| 8 | 9 | import { serve } from "./server.ts"; |
| 10 | import { GUEST_COOKIE, guestId, newGuestKey } from "./session.ts"; | |
| 9 | 11 | import { |
| 10 | 12 | admit, |
| 11 | 13 | backedUp, |
| @@ -14,11 +16,16 @@ import { | ||
| 14 | 16 | publish, |
| 15 | 17 | receive, |
| 16 | 18 | seatHolder, |
| 19 | seatOf, | |
| 20 | seating, | |
| 21 | seatWindows, | |
| 17 | 22 | SEND_MAX_BYTES, |
| 18 | 23 | SILENCE_MS, |
| 19 | 24 | socketCount, |
| 20 | 25 | socketsFrom, |
| 26 | socketsOn, | |
| 21 | 27 | SOCKET_MAX_SHARED, |
| 28 | SOCKETS_PER_SEAT, | |
| 22 | 29 | type Sock, |
| 23 | 30 | type SocketData, |
| 24 | 31 | type Steering, |
| @@ -55,6 +62,11 @@ const as = (id: string, name: string): Record<string, string> => { | ||
| 55 | 62 | }; |
| 56 | 63 | }; |
| 57 | 64 | |
| 65 | /** The cookie jar a guest's browser carries — the only way a guest is known. */ | |
| 66 | const jar = (key: string): Record<string, string> => ({ | |
| 67 | cookie: `${GUEST_COOKIE}=${key}`, | |
| 68 | }); | |
| 69 | ||
| 58 | 70 | interface Client { |
| 59 | 71 | readonly ws: WebSocket; |
| 60 | 72 | readonly opened: Promise<boolean>; |
| @@ -188,7 +200,7 @@ test("identity is fixed at upgrade, and no message can name its own sender", asy | ||
| 188 | 200 | // the socket's own seat. |
| 189 | 201 | client.ws.send(JSON.stringify({ x: 1, y: 0, seq: 1, key: "user:u2" })); |
| 190 | 202 | await until("a steer", () => heard.length === 1); |
| 191 | expect(heard[0]!.key).toBe("user:u1"); | |
| 203 | expect(heard[0]!.key).toBe("u1"); | |
| 192 | 204 | expect(heard[0]!.who).toEqual({ role: "user", id: "u1", name: "alice" }); |
| 193 | 205 | expect(Object.keys(heard[0]!.steer).sort()).toEqual(["seq", "x", "y"]); |
| 194 | 206 | |
| @@ -255,7 +267,7 @@ test("a malformed message closes the socket with 1008", async () => { | ||
| 255 | 267 | test("a normal 20 Hz steer passes, minute after minute", () => { |
| 256 | 268 | const heard: number[] = []; |
| 257 | 269 | const stop = onSteer((steering) => heard.push(steering.steer.seq)); |
| 258 | const sock = fake("10.4.4.4"); | |
| 270 | const sock = fake("10.4.4.4", ANON, newGuestKey()); | |
| 259 | 271 | websocket.open(sock); |
| 260 | 272 | // Two minutes of a thumb on the stick, at the rate the page will send: |
| 261 | 273 | // 2400 steers, 50 ms apart on the clock, and not one of them dropped. |
| @@ -302,31 +314,257 @@ test("a socket that keeps flooding is closed with 1008", async () => { | ||
| 302 | 314 | |
| 303 | 315 | // ---------------------------------------------------------------- a seat |
| 304 | 316 | |
| 305 | test("a seat is a person, not a tab: the newest socket wins", async () => { | |
| 317 | test("a seat is a person, not a tab: both tabs live and steer one mote", async () => { | |
| 318 | const heard: Steering[] = []; | |
| 319 | const stop = onSteer((steering) => heard.push(steering)); | |
| 306 | 320 | const first = connect(as("u9", "ida")); |
| 307 | 321 | expect(await first.opened).toBe(true); |
| 308 | expect(seatHolder("user:u9")).toBeDefined(); | |
| 322 | expect(socketsOn("u9")).toBe(1); | |
| 309 | 323 | |
| 324 | // The second tab is refused no more than the first, and it does not close | |
| 325 | // it: two windows, one seat, and the roster still shows one swimmer. | |
| 310 | 326 | const second = connect(as("u9", "ida")); |
| 311 | 327 | expect(await second.opened).toBe(true); |
| 312 | ||
| 313 | const end = await first.closed; | |
| 314 | expect(end.code).toBe(CLOSE.replaced); | |
| 328 | await until("both windows", () => socketsOn("u9") === 2); | |
| 329 | expect(first.ws.readyState).toBe(WebSocket.OPEN); | |
| 315 | 330 | expect(second.ws.readyState).toBe(WebSocket.OPEN); |
| 316 | // One person, one seat — and the seat is the survivor's, not the ghost's. | |
| 317 | await until("the old socket to be forgotten", () => socketCount() === 1); | |
| 318 | expect(seatHolder("user:u9")).toBeDefined(); | |
| 319 | 331 | |
| 332 | // Both steer the same seat, so the mote they move is the same one. | |
| 333 | first.ws.send(JSON.stringify({ x: 1, y: 0, seq: 1 })); | |
| 334 | second.ws.send(JSON.stringify({ x: 0, y: 1, seq: 2 })); | |
| 335 | await until("two steers", () => heard.length === 2); | |
| 336 | expect(heard.map((steering) => steering.key)).toEqual(["u9", "u9"]); | |
| 337 | ||
| 338 | // Closing one window leaves the seat with the other; the key goes when the | |
| 339 | // last of them does. | |
| 340 | first.bye(); | |
| 341 | await first.closed; | |
| 342 | await until("one window", () => socketsOn("u9") === 1); | |
| 343 | expect(seatHolder("u9")).toBeDefined(); | |
| 344 | ||
| 345 | stop(); | |
| 320 | 346 | second.bye(); |
| 321 | 347 | await second.closed; |
| 322 | await until("an empty world", () => seatHolder("user:u9") === undefined); | |
| 348 | await until("an empty world", () => seatHolder("u9") === undefined); | |
| 349 | }); | |
| 350 | ||
| 351 | test("a key past its windows watches, rather than closing one that broke no rule", () => { | |
| 352 | const key = newGuestKey(); | |
| 353 | const id = guestId(key); | |
| 354 | const open: Fake[] = []; | |
| 355 | for (let i = 0; i < SOCKETS_PER_SEAT; i++) { | |
| 356 | const sock = fake(`10.8.8.${i}`, ANON, key); | |
| 357 | websocket.open(sock); | |
| 358 | open.push(sock); | |
| 359 | } | |
| 360 | expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT); | |
| 361 | for (const sock of open) expect(sock.closes).toEqual([]); | |
| 362 | ||
| 363 | // One window too many: admitted, seated nowhere, and told so — the same | |
| 364 | // answer overflow gets everywhere else. Nothing already open is disturbed. | |
| 365 | const extra = fake("10.8.8.9", ANON, key); | |
| 366 | websocket.open(extra); | |
| 367 | expect(extra.data.key).toBeUndefined(); | |
| 368 | expect(seating(extra.data)).toBe("spectator"); | |
| 369 | expect(extra.sent).toEqual([SPECTATOR]); | |
| 370 | expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT); | |
| 371 | for (const sock of open) expect(sock.closes).toEqual([]); | |
| 372 | ||
| 373 | // A window closes, and the seat has room again. | |
| 374 | open[0]!.close(); | |
| 375 | expect(socketsOn(id)).toBe(SOCKETS_PER_SEAT - 1); | |
| 376 | const back = fake("10.8.8.10", ANON, key); | |
| 377 | expect(back.data.key).toBe(id); | |
| 378 | ||
| 379 | extra.close(); | |
| 380 | for (const sock of open.slice(1)) sock.close(); | |
| 381 | expect(socketsOn(id)).toBe(0); | |
| 382 | expect(seatHolder(id)).toBeUndefined(); | |
| 383 | }); | |
| 384 | ||
| 385 | test("a seat is the player key and nothing a connection invents (invariant 7)", () => { | |
| 386 | // The whole sentence, not the half the code finds convenient: a second | |
| 387 | // connection under a key *steers that key's own mote*, and a request with | |
| 388 | // no session key gets no mote at all. | |
| 389 | expect(INVARIANTS[6]).toStartWith("One key, one seat"); | |
| 390 | expect(INVARIANTS[6]).toContain( | |
| 391 | "a second connection under a key steers that key's own mote", | |
| 392 | ); | |
| 393 | expect(INVARIANTS[6]).toContain( | |
| 394 | "a request carrying no session key gets no mote at all", | |
| 395 | ); | |
| 396 | const key = newGuestKey(); | |
| 397 | const mine = upgradeHeaders({ origin: publicOrigin, ...jar(key) }); | |
| 398 | ||
| 399 | // The same cookie is the same seat, every time it is asked — no minting. | |
| 400 | const again = upgradeHeaders({ origin: publicOrigin, ...jar(key) }); | |
| 401 | const theirs = upgradeHeaders({ | |
| 402 | origin: publicOrigin, | |
| 403 | ...jar(newGuestKey()), | |
| 404 | }); | |
| 405 | expect(seatOf(mine, ANON)).toBe(guestId(key)); | |
| 406 | expect(seatOf(again, ANON)).toBe(guestId(key)); | |
| 407 | // Another cookie is another guest. | |
| 408 | expect(seatOf(theirs, ANON)).not.toBe(guestId(key)); | |
| 409 | // The key the cookie holds is a bearer secret; the seat is a one-way | |
| 410 | // derivation of it, and the raw key is never the seat. | |
| 411 | expect(seatOf(mine, ANON)).not.toBe(key); | |
| 412 | // No cookie at all is no seat: nothing is minted to fill the hole. | |
| 413 | expect( | |
| 414 | seatOf(upgradeHeaders({ origin: publicOrigin }), ANON), | |
| 415 | ).toBeUndefined(); | |
| 416 | // A signed-in person is their forge id, whatever cookie they also carry. | |
| 417 | const kit: Who = { role: "user", id: "u11", name: "kit" }; | |
| 418 | expect(seatOf(mine, kit)).toBe("u11"); | |
| 419 | expect(seatOf(upgradeHeaders({ origin: publicOrigin }), kit)).toBe("u11"); | |
| 420 | }); | |
| 421 | ||
| 422 | test("a guest is only ever the cookie: nothing a client can choose is read", () => { | |
| 423 | const key = newGuestKey(); | |
| 424 | // The guest key offered in a query string, in a header of the client's | |
| 425 | // own choosing, or as a bearer token is not a session and never will be. | |
| 426 | const smuggled = new Request( | |
| 427 | `${publicOrigin}/ws?guest=${key}&key=${guestId(key)}`, | |
| 428 | { | |
| 429 | headers: { | |
| 430 | connection: "Upgrade", | |
| 431 | upgrade: "websocket", | |
| 432 | origin: publicOrigin, | |
| 433 | "x-guest": key, | |
| 434 | authorization: `Bearer ${key}`, | |
| 435 | }, | |
| 436 | }, | |
| 437 | ); | |
| 438 | expect(seatOf(smuggled, ANON)).toBeUndefined(); | |
| 439 | const seat = admit(smuggled, ANON, "10.3.3.1"); | |
| 440 | expect(seat.ok && seating(seat.data)).toBe("spectator"); | |
| 441 | }); | |
| 442 | ||
| 443 | test("two guest sockets carrying one cookie are two windows on one seat", async () => { | |
| 444 | const heard: Steering[] = []; | |
| 445 | const stop = onSteer((steering) => heard.push(steering)); | |
| 446 | const key = newGuestKey(); | |
| 447 | const id = guestId(key); | |
| 448 | const first = connect(jar(key)); | |
| 449 | expect(await first.opened).toBe(true); | |
| 450 | expect(socketsOn(id)).toBe(1); | |
| 451 | ||
| 452 | // The second tab is the same key, so it is the same seat: admitted, given | |
| 453 | // no seat of its own, and taking nothing from the tab that was already | |
| 454 | // there. One key, one seat, two windows. | |
| 455 | const second = connect(jar(key)); | |
| 456 | expect(await second.opened).toBe(true); | |
| 457 | await until("both windows", () => socketsOn(id) === 2); | |
| 458 | expect(first.ws.readyState).toBe(WebSocket.OPEN); | |
| 459 | expect(second.ws.readyState).toBe(WebSocket.OPEN); | |
| 460 | for (const ws of seatWindows(id)) expect(ws.data.key).toBe(id); | |
| 461 | ||
| 462 | second.ws.send(JSON.stringify({ x: 0, y: 1, seq: 1 })); | |
| 463 | await until("a steer", () => heard.length === 1); | |
| 464 | expect(heard[0]!.key).toBe(id); | |
| 465 | ||
| 466 | stop(); | |
| 467 | first.bye(); | |
| 468 | second.bye(); | |
| 469 | await first.closed; | |
| 470 | await second.closed; | |
| 471 | await until("an empty world", () => seatHolder(id) === undefined); | |
| 472 | }); | |
| 473 | ||
| 474 | test("two guest sockets with different cookies hold two seats", async () => { | |
| 475 | const mine = newGuestKey(); | |
| 476 | const yours = newGuestKey(); | |
| 477 | const one = connect(jar(mine)); | |
| 478 | const two = connect(jar(yours)); | |
| 479 | expect(await one.opened).toBe(true); | |
| 480 | expect(await two.opened).toBe(true); | |
| 481 | ||
| 482 | expect(seatHolder(guestId(mine))).toBeDefined(); | |
| 483 | expect(seatHolder(guestId(yours))).toBeDefined(); | |
| 484 | expect(seatHolder(guestId(mine))).not.toBe(seatHolder(guestId(yours))); | |
| 485 | expect(one.ws.readyState).toBe(WebSocket.OPEN); | |
| 486 | expect(two.ws.readyState).toBe(WebSocket.OPEN); | |
| 487 | ||
| 488 | one.bye(); | |
| 489 | two.bye(); | |
| 490 | await one.closed; | |
| 491 | await two.closed; | |
| 492 | await until("an empty world", () => socketCount() === 0); | |
| 493 | }); | |
| 494 | ||
| 495 | test("signing in releases the guest seat in the tick the forge id takes one", async () => { | |
| 496 | const key = newGuestKey(); | |
| 497 | const guest = connect(jar(key)); | |
| 498 | const otherTab = connect(jar(key)); | |
| 499 | expect(await guest.opened).toBe(true); | |
| 500 | expect(await otherTab.opened).toBe(true); | |
| 501 | await until("both windows", () => socketsOn(guestId(key)) === 2); | |
| 502 | ||
| 503 | // The same browser, now named by the forge and still carrying its cookie: | |
| 504 | // the guest seat is let go, every window that was open on it with it, so | |
| 505 | // nobody holds two and nothing keeps steering a key its person has left. | |
| 506 | // What the new mote inherits from the old one is the round's. | |
| 507 | const signedIn = connect({ ...as("u12", "lune"), ...jar(key) }); | |
| 508 | expect(await signedIn.opened).toBe(true); | |
| 509 | expect((await guest.closed).code).toBe(CLOSE.replaced); | |
| 510 | expect((await otherTab.closed).code).toBe(CLOSE.replaced); | |
| 511 | await until("the guest sockets to be forgotten", () => socketCount() === 1); | |
| 512 | expect(seatHolder(guestId(key))).toBeUndefined(); | |
| 513 | expect(seatHolder("u12")?.data.key).toBe("u12"); | |
| 514 | ||
| 515 | signedIn.bye(); | |
| 516 | await signedIn.closed; | |
| 517 | await until("an empty world", () => socketCount() === 0); | |
| 518 | }); | |
| 519 | ||
| 520 | // ----------------------------------------------------------- a spectator | |
| 521 | ||
| 522 | test("a socket with no cookie holds no seat, opens anyway, and is told so", async () => { | |
| 523 | const client = connect(); | |
| 524 | expect(await client.opened).toBe(true); | |
| 525 | // Nobody is turned away and nothing is refused silently: the sentence is | |
| 526 | // the record's, word for word, not a second wording invented here. | |
| 527 | await until("the sentence", () => client.got.length === 1); | |
| 528 | expect(client.got[0]).toBe(SPECTATOR); | |
| 529 | // And the frames still come down it. | |
| 530 | expect(publish("a frame").sent).toBe(1); | |
| 531 | await until("the frame", () => client.got.length === 2); | |
| 532 | expect(client.got[1]).toBe("a frame"); | |
| 533 | ||
| 534 | client.bye(); | |
| 535 | await client.closed; | |
| 536 | await until("an empty world", () => socketCount() === 0); | |
| 537 | }); | |
| 538 | ||
| 539 | test("steering from a spectator moves nothing, and costs them no socket", () => { | |
| 540 | const heard: Steering[] = []; | |
| 541 | const stop = onSteer((steering) => heard.push(steering)); | |
| 542 | const sock = fake("10.7.7.7"); | |
| 543 | websocket.open(sock); | |
| 544 | expect(sock.data.key).toBeUndefined(); | |
| 545 | expect(seating(sock.data)).toBe("spectator"); | |
| 546 | expect(sock.sent).toEqual([SPECTATOR]); | |
| 547 | ||
| 548 | // A perfectly good steer, dropped where it arrives the way a steer over | |
| 549 | // the ration is: counted, not delivered, and not a reason to close. | |
| 550 | let at = sock.data.heard; | |
| 551 | for (let seq = 0; seq < 5; seq++, at += 50) | |
| 552 | receive(sock, JSON.stringify({ x: 0, y: 1, seq }), at); | |
| 553 | expect(heard).toEqual([]); | |
| 554 | expect(sock.data.dropped).toBe(5); | |
| 555 | expect(sock.closes).toEqual([]); | |
| 556 | ||
| 557 | // The rules a message breaks are still the rules: watching is not licence. | |
| 558 | receive(sock, "steer left a bit", at); | |
| 559 | expect(sock.closes[0]?.code).toBe(CLOSE.policy); | |
| 560 | stop(); | |
| 323 | 561 | }); |
| 324 | 562 | |
| 325 | 563 | // ------------------------------------------------------- frames going out |
| 326 | 564 | |
| 327 | 565 | test("publish skips a backed-up socket and never waits for it", () => { |
| 328 | const quick = fake("10.5.5.5"); | |
| 329 | const slow = fake("10.5.5.6"); | |
| 566 | const quick = fake("10.5.5.5", ANON, newGuestKey()); | |
| 567 | const slow = fake("10.5.5.6", ANON, newGuestKey()); | |
| 330 | 568 | slow.buffered = SEND_MAX_BYTES + 1; |
| 331 | 569 | websocket.open(quick); |
| 332 | 570 | websocket.open(slow); |
| @@ -348,11 +586,11 @@ test("publish skips a backed-up socket and never waits for it", () => { | ||
| 348 | 586 | }); |
| 349 | 587 | |
| 350 | 588 | test("a socket that fails to send is closed, and the rest still get the frame", () => { |
| 351 | const broken = fake("10.5.5.7"); | |
| 589 | const broken = fake("10.5.5.7", ANON, newGuestKey()); | |
| 352 | 590 | broken.send = () => { |
| 353 | 591 | throw new Error("the peer went away mid-frame"); |
| 354 | 592 | }; |
| 355 | const fine = fake("10.5.5.8"); | |
| 593 | const fine = fake("10.5.5.8", ANON, newGuestKey()); | |
| 356 | 594 | websocket.open(broken); |
| 357 | 595 | websocket.open(fine); |
| 358 | 596 | |
| @@ -416,9 +654,19 @@ interface Fake extends Sock { | ||
| 416 | 654 | * part of `ServerWebSocket` they use, so a test can be one and turn the |
| 417 | 655 | * clock by hand. Closing behaves the way Bun behaves — the close handler |
| 418 | 656 | * runs — so the registry is exercised, not bypassed. |
| 657 | * | |
| 658 | * With no `guest` key it carries no session at all, which is a spectator; | |
| 659 | * a test that needs a seat hands it one, as a browser's cookie would. | |
| 419 | 660 | */ |
| 420 | function fake(address: string, who: Who = ANON): Fake { | |
| 421 | const seat = admit(upgradeHeaders({ origin: publicOrigin }), who, address); | |
| 661 | function fake(address: string, who: Who = ANON, guest?: string): Fake { | |
| 662 | const seat = admit( | |
| 663 | upgradeHeaders({ | |
| 664 | origin: publicOrigin, | |
| 665 | ...(guest === undefined ? {} : jar(guest)), | |
| 666 | }), | |
| 667 | who, | |
| 668 | address, | |
| 669 | ); | |
| 422 | 670 | if (!seat.ok) throw new Error(`admission refused: ${seat.why}`); |
| 423 | 671 | const data: SocketData = seat.data; |
| 424 | 672 | const sock: Fake = { |
src/socket.ts +188 −44
| @@ -18,9 +18,18 @@ | ||
| 18 | 18 | * ration a token bucket per socket: 20 Hz steering passes, a flood is |
| 19 | 19 | * dropped where it arrives rather than queued, and a socket |
| 20 | 20 | * that keeps flooding is closed. |
| 21 | * seat a seat is a person, not a tab: newest wins. A second socket | |
| 22 | * on one player key closes the first with 4000, so one person | |
| 23 | * holds one mote however many tabs they open. | |
| 21 | * seat a seat is a player key (`playerKey` in `session.ts`), not a | |
| 22 | * tab: every socket under one key is another window on the | |
| 23 | * same seat, steering the same mote. The sixth tab is refused | |
| 24 | * no more than the first, and none of them closes another. | |
| 25 | * Signing in is a change of key, and the guest seat the same | |
| 26 | * browser still holds is released then — the one thing that | |
| 27 | * closes a socket that broke no rule, with 4000. | |
| 28 | * spectator a request carrying no session has no key, so it has no seat, | |
| 29 | * and so does one past `SOCKETS_PER_SEAT` windows on its own. | |
| 30 | * It is admitted anyway, told so in the record's own sentence, | |
| 31 | * and steers nothing: nobody is turned away and nothing is | |
| 32 | * refused silently. | |
| 24 | 33 | * liveness a ping every 25 s (inside the server's 60 s `idleTimeout`), |
| 25 | 34 | * and a socket that has said nothing — message or pong — for |
| 26 | 35 | * 90 s is closed with 4001. |
| @@ -32,11 +41,17 @@ | ||
| 32 | 41 | * drive at 10 Hz; today a test drives it. |
| 33 | 42 | */ |
| 34 | 43 | |
| 35 | import { randomBytes } from "node:crypto"; | |
| 44 | import { SPECTATOR } from "./concept.ts"; | |
| 36 | 45 | import { SECURITY, socketOrigin, text } from "./http.ts"; |
| 37 | import { FRAME_MAX_BYTES, parseSteer, type Steer } from "./protocol.ts"; | |
| 46 | import { | |
| 47 | encodeSentence, | |
| 48 | FRAME_MAX_BYTES, | |
| 49 | parseSteer, | |
| 50 | type Steer, | |
| 51 | } from "./protocol.ts"; | |
| 38 | 52 | import type { Context } from "./routes.ts"; |
| 39 | import type { Who } from "./who.ts"; | |
| 53 | import { playerKey } from "./session.ts"; | |
| 54 | import { ANON, type Who } from "./who.ts"; | |
| 40 | 55 | |
| 41 | 56 | /** How many steers a socket may spend at once, and how fast it earns more. */ |
| 42 | 57 | const STEER_BURST = 40; |
| @@ -65,19 +80,32 @@ export const SEND_MAX_BYTES = FRAME_MAX_BYTES * 8; | ||
| 65 | 80 | */ |
| 66 | 81 | const MAX_PAYLOAD_BYTES = 8 * 1024; |
| 67 | 82 | /** |
| 68 | * How many sockets one address may hold open at once. The seat rule bounds a | |
| 69 | * person at one; this bounds everyone the app cannot tell apart, which behind | |
| 83 | * How many sockets one address may hold open at once. `SOCKETS_PER_SEAT` | |
| 84 | * bounds one key; this bounds everyone the app cannot tell apart, which behind | |
| 70 | 85 | * the forge is every guest arriving from the forge's address — so it is a |
| 71 | 86 | * ceiling on this process, like `STREAM_MAX_SHARED` in `live.ts`, rather than |
| 72 | * a quota on anybody. | |
| 87 | * a quota on anybody. It is also the only bound on sockets holding no | |
| 88 | * session, and it can be: they take no seat and steer nothing. | |
| 73 | 89 | */ |
| 74 | 90 | export const SOCKET_MAX_SHARED = 256; |
| 91 | /** | |
| 92 | * How many windows one seat may have open at once. The record's own story is | |
| 93 | * a person with six tabs when a round stalls, so six costs them nothing; | |
| 94 | * past this a connection is not refused, it watches, because a browser with | |
| 95 | * a stuck reconnect is a person, not an attacker. Every one of them steers | |
| 96 | * the same mote, so this bounds bookkeeping rather than anybody's influence. | |
| 97 | */ | |
| 98 | export const SOCKETS_PER_SEAT = 8; | |
| 75 | 99 | |
| 76 | 100 | /** Why a socket closed. 1008 is the protocol's word for "you broke a rule". */ |
| 77 | 101 | export const CLOSE = { |
| 78 | 102 | /** A message the rules refuse, or a flood that would not stop. */ |
| 79 | 103 | policy: 1008, |
| 80 | /** The same person opened another socket; this one is the older tab. */ | |
| 104 | /** | |
| 105 | * The key under this socket is not this browser's any more: it signed in, | |
| 106 | * and the guest seat it was holding is released. Its own code so a client | |
| 107 | * can tell "reconnect, you are somebody else now" from "you broke a rule". | |
| 108 | */ | |
| 81 | 109 | replaced: 4000, |
| 82 | 110 | /** Nothing heard for `SILENCE_MS`. */ |
| 83 | 111 | silent: 4001, |
| @@ -85,12 +113,26 @@ export const CLOSE = { | ||
| 85 | 113 | broken: 1011, |
| 86 | 114 | } as const; |
| 87 | 115 | |
| 116 | /** What a socket may do with the world: steer one mote, or only watch it. */ | |
| 117 | export type Seating = "swimmer" | "spectator"; | |
| 118 | ||
| 88 | 119 | /** What a socket knows about itself, decided once, at upgrade. */ |
| 89 | 120 | export interface SocketData { |
| 90 | 121 | /** The identity the forge signed, or `anon`. Never anything a message said. */ |
| 91 | 122 | readonly who: Who; |
| 92 | /** The seat: one player key, one mote. */ | |
| 93 | readonly key: string; | |
| 123 | /** | |
| 124 | * The seat: one player key, one mote, however many sockets sit under it. | |
| 125 | * Nothing at all when the request carried no session, or when that key | |
| 126 | * already has its windows open — either way this socket is a spectator. | |
| 127 | */ | |
| 128 | readonly key: string | undefined; | |
| 129 | /** | |
| 130 | * The guest seat this same browser also carries, when the forge named its | |
| 131 | * person too: signing in is a change of key, so the guest seat is let go in | |
| 132 | * the tick the forge id takes one. Nothing for a socket that is not signed | |
| 133 | * in — a guest's own key is in `key` already. | |
| 134 | */ | |
| 135 | readonly guest: string | undefined; | |
| 94 | 136 | /** What the shared cap counts against. */ |
| 95 | 137 | readonly address: string; |
| 96 | 138 | readonly openedAt: number; |
| @@ -106,6 +148,14 @@ export interface SocketData { | ||
| 106 | 148 | skipped: number; |
| 107 | 149 | } |
| 108 | 150 | |
| 151 | /** | |
| 152 | * Which of the two a socket is, read from the one field that decides it: a | |
| 153 | * socket with a key steers a mote, a socket without one watches. Derived, | |
| 154 | * never stored, so there is no second copy to disagree with the first. | |
| 155 | */ | |
| 156 | export const seating = (data: SocketData): Seating => | |
| 157 | data.key === undefined ? "spectator" : "swimmer"; | |
| 158 | ||
| 109 | 159 | /** The part of a `ServerWebSocket` this module uses, so a test can be one. */ |
| 110 | 160 | export interface Sock { |
| 111 | 161 | readonly data: SocketData; |
| @@ -124,7 +174,12 @@ export interface Steering { | ||
| 124 | 174 | } |
| 125 | 175 | |
| 126 | 176 | const live = new Set<Sock>(); |
| 127 | const seats = new Map<string, Sock>(); | |
| 177 | /** | |
| 178 | * One key, one seat, many windows: every socket under a key sits in the same | |
| 179 | * seat and steers the same mote. A key with no sockets left is not a seat at | |
| 180 | * all, and the entry goes. | |
| 181 | */ | |
| 182 | const seats = new Map<string, Set<Sock>>(); | |
| 128 | 183 | const byAddress = new Map<string, number>(); |
| 129 | 184 | const listeners = new Set<(steering: Steering) => void>(); |
| 130 | 185 | |
| @@ -134,8 +189,17 @@ export const socketCount = (): number => live.size; | ||
| 134 | 189 | /** How many sockets an address holds open, for the cap and for the tests. */ |
| 135 | 190 | export const socketsFrom = (address: string): number => |
| 136 | 191 | byAddress.get(address) ?? 0; |
| 137 | /** The socket sitting in a seat, if anybody is. */ | |
| 138 | export const seatHolder = (key: string): Sock | undefined => seats.get(key); | |
| 192 | /** Every window on one seat. Empty when nobody holds it. */ | |
| 193 | export const seatWindows = (key: string): ReadonlySet<Sock> => | |
| 194 | seats.get(key) ?? EMPTY; | |
| 195 | /** How many windows a seat has open, for the per-key cap and for the tests. */ | |
| 196 | export const socketsOn = (key: string): number => seats.get(key)?.size ?? 0; | |
| 197 | /** A socket sitting in a seat, if anybody is — any of them, since it is one seat. */ | |
| 198 | export const seatHolder = (key: string): Sock | undefined => { | |
| 199 | for (const ws of seatWindows(key)) return ws; | |
| 200 | return undefined; | |
| 201 | }; | |
| 202 | const EMPTY: ReadonlySet<Sock> = new Set(); | |
| 139 | 203 | |
| 140 | 204 | /** |
| 141 | 205 | * Steering, as it arrives. #BUS and the round listen; a listener is handed |
| @@ -147,21 +211,31 @@ export function onSteer(listen: (steering: Steering) => void): () => void { | ||
| 147 | 211 | } |
| 148 | 212 | |
| 149 | 213 | /** |
| 150 | * The seat a request plays as. A person the forge named is their forge id, | |
| 151 | * which is the same across tabs, reloads and reconnects — that is what makes | |
| 152 | * a seat a person. | |
| 214 | * The seat a request plays as: the player key and nothing else. A person the | |
| 215 | * forge named is their forge id, which is the same across tabs, reloads and | |
| 216 | * reconnects; everybody else is the guest id derived from the `HttpOnly` | |
| 217 | * cookie the app set (`playerKey` in `src/session.ts`). That derivation is | |
| 218 | * the only way a guest is recognised here — never a query string, never a | |
| 219 | * header a client chose, never a message the socket sent. | |
| 153 | 220 | * |
| 154 | * A guest is the one thing this module cannot decide alone: a guest's | |
| 155 | * identity is a cookie, and reading that cookie is `src/session.ts` in | |
| 156 | * #DATA, which is not merged yet. Until it is, an anonymous socket is its | |
| 157 | * own seat — every guest tab is its own connection — and the shared cap | |
| 158 | * above is what bounds them. When #DATA lands this is one import and one | |
| 159 | * line: `playerKey(request, who)`. | |
| 221 | * A request carrying no session at all gets nothing, and nothing is minted | |
| 222 | * to fill the hole: an upgrade has no response to set a cookie on, so a key | |
| 223 | * invented here would be a new seat on every connection, which is the bug | |
| 224 | * this replaced. A browser's first visit is a page load — which sets the | |
| 225 | * cookie — and then a socket, one round trip, and until it holds a cookie it | |
| 226 | * watches (`SPECTATOR`). | |
| 160 | 227 | */ |
| 161 | export const seatOf = (_request: Request, who: Who): string => | |
| 162 | who.role === "anon" | |
| 163 | ? `anon:${randomBytes(12).toString("base64url")}` | |
| 164 | : `user:${who.id}`; | |
| 228 | export const seatOf = (request: Request, who: Who): string | undefined => | |
| 229 | playerKey(request, who); | |
| 230 | ||
| 231 | /** | |
| 232 | * The guest seat this request *also* carries, when the forge has named its | |
| 233 | * person. Signing in is a change of key, so it is a change of seat: this is | |
| 234 | * the key to release, and it is the same derivation `seatOf` makes for a | |
| 235 | * guest — the id this browser would swim under if it were not signed in. | |
| 236 | */ | |
| 237 | const guestSeatOf = (request: Request, who: Who): string | undefined => | |
| 238 | who.role === "anon" ? undefined : playerKey(request, ANON); | |
| 165 | 239 | |
| 166 | 240 | export type Admission = |
| 167 | 241 | | { readonly ok: true; readonly data: SocketData } |
| @@ -186,11 +260,21 @@ export function admit( | ||
| 186 | 260 | return { ok: false, status: 403, why: "forbidden" }; |
| 187 | 261 | if (socketsFrom(address) >= SOCKET_MAX_SHARED) |
| 188 | 262 | return { ok: false, status: 503, why: "too many sockets" }; |
| 263 | // No session, no seat — and a seat already showing `SOCKETS_PER_SEAT` | |
| 264 | // windows takes no more of them. Neither is refused: the overflow answer | |
| 265 | // is one answer everywhere, and it is that you see the whole round, steer | |
| 266 | // nothing, and are told so. | |
| 267 | const seat = seatOf(request, who); | |
| 268 | const key = | |
| 269 | seat !== undefined && socketsOn(seat) >= SOCKETS_PER_SEAT | |
| 270 | ? undefined | |
| 271 | : seat; | |
| 189 | 272 | return { |
| 190 | 273 | ok: true, |
| 191 | 274 | data: { |
| 192 | 275 | who, |
| 193 | key: seatOf(request, who), | |
| 276 | key, | |
| 277 | guest: guestSeatOf(request, who), | |
| 194 | 278 | address, |
| 195 | 279 | openedAt: now, |
| 196 | 280 | heard: now, |
| @@ -233,20 +317,24 @@ export const websocket = { | ||
| 233 | 317 | perMessageDeflate: false, |
| 234 | 318 | |
| 235 | 319 | open(ws: Sock): void { |
| 236 | const { key, address } = ws.data; | |
| 237 | // A seat is a person, not a tab: newest wins. The older socket is closed | |
| 238 | // with its own code so a client can tell "you opened me somewhere else" | |
| 239 | // from "you broke a rule", and a reconnect after a deploy takes the seat | |
| 240 | // back rather than fighting a ghost for it. | |
| 241 | const held = seats.get(key); | |
| 242 | if (held !== undefined && held !== ws) { | |
| 243 | seats.delete(key); | |
| 244 | close(held, CLOSE.replaced, "replaced by a newer connection"); | |
| 245 | } | |
| 246 | seats.set(key, ws); | |
| 320 | const { key, guest, address } = ws.data; | |
| 321 | // Signing in is a change of key, so it is a change of seat: the guest | |
| 322 | // seat this same browser still holds is let go in the tick the forge id | |
| 323 | // takes one, so nobody holds two. What the new mote inherits from the old | |
| 324 | // one is the round's (#8); the socket's half is only the release. | |
| 325 | if (guest !== undefined) release(guest, ws, "signed in"); | |
| 326 | // A seat is a person, not a tab: this socket joins the windows already | |
| 327 | // on its key rather than taking one from them. Six tabs are six windows | |
| 328 | // on one mote — the sixth is refused no more than the first, and neither | |
| 329 | // of them can close the other. A spectator has no key and sits in no | |
| 330 | // seat, so it displaces nobody either. | |
| 331 | if (key !== undefined) window(key).add(ws); | |
| 247 | 332 | live.add(ws); |
| 248 | 333 | byAddress.set(address, socketsFrom(address) + 1); |
| 249 | 334 | start(); |
| 335 | // Told, in the record's own words, the moment the socket is real: frames | |
| 336 | // will still come down it and nothing it sends will move them. | |
| 337 | if (seating(ws.data) === "spectator") tell(ws, SPECTATOR); | |
| 250 | 338 | }, |
| 251 | 339 | |
| 252 | 340 | message(ws: Sock, message: string | Uint8Array): void { |
| @@ -259,9 +347,15 @@ export const websocket = { | ||
| 259 | 347 | |
| 260 | 348 | close(ws: Sock): void { |
| 261 | 349 | live.delete(ws); |
| 262 | // Only if the seat is still this socket's: a replaced socket closes | |
| 263 | // *after* the new one took the seat, and must not take it away again. | |
| 264 | if (seats.get(ws.data.key) === ws) seats.delete(ws.data.key); | |
| 350 | // One window closes; the others keep the seat. The key goes only when | |
| 351 | // the last of them has gone — and a released socket closes *after* its | |
| 352 | // key was taken away, which this handles by taking away only itself. | |
| 353 | const { key } = ws.data; | |
| 354 | const held = key === undefined ? undefined : seats.get(key); | |
| 355 | if (key !== undefined && held !== undefined) { | |
| 356 | held.delete(ws); | |
| 357 | if (held.size === 0) seats.delete(key); | |
| 358 | } | |
| 265 | 359 | const left = socketsFrom(ws.data.address) - 1; |
| 266 | 360 | if (left > 0) byAddress.set(ws.data.address, left); |
| 267 | 361 | else byAddress.delete(ws.data.address); |
| @@ -296,10 +390,19 @@ export function receive( | ||
| 296 | 390 | const parsed = parseSteer(message); |
| 297 | 391 | if (!parsed.ok) return close(ws, CLOSE.policy, parsed.error); |
| 298 | 392 | if (ws.data.strikes > 0) ws.data.strikes -= 1; |
| 393 | // A spectator has no seat to steer. Their message is dropped where it | |
| 394 | // arrives, exactly as a steer over the ration is — counted, not delivered, | |
| 395 | // and never a reason to close a socket that broke no rule. They were told | |
| 396 | // they were watching when they connected. | |
| 397 | const key = ws.data.key; | |
| 398 | if (key === undefined) { | |
| 399 | ws.data.dropped += 1; | |
| 400 | return; | |
| 401 | } | |
| 299 | 402 | // The identity is the socket's, fixed at upgrade. A message that names a |
| 300 | 403 | // player names nothing: `parseSteer` keeps three numbers and drops the rest. |
| 301 | 404 | const steering: Steering = { |
| 302 | key: ws.data.key, | |
| 405 | key, | |
| 303 | 406 | who: ws.data.who, |
| 304 | 407 | steer: parsed.value, |
| 305 | 408 | at: now, |
| @@ -402,6 +505,47 @@ const buffered = (ws: Sock): number => { | ||
| 402 | 505 | } |
| 403 | 506 | }; |
| 404 | 507 | |
| 508 | /** The windows on one seat, made the first time somebody sits in it. */ | |
| 509 | function window(key: string): Set<Sock> { | |
| 510 | const held = seats.get(key); | |
| 511 | if (held !== undefined) return held; | |
| 512 | const made = new Set<Sock>(); | |
| 513 | seats.set(key, made); | |
| 514 | return made; | |
| 515 | } | |
| 516 | ||
| 517 | /** | |
| 518 | * Let a key go, and close every window that was open on it. The one thing | |
| 519 | * that closes a socket which broke no rule, and it happens for one reason: | |
| 520 | * the browser signed in, so the guest key it was sitting under is not its | |
| 521 | * key any more. Leaving those sockets open would leave them steering a seat | |
| 522 | * their person no longer holds; closing them with `replaced` tells the page | |
| 523 | * to come back as who it is now. | |
| 524 | */ | |
| 525 | function release(key: string, taking: Sock, reason: string): void { | |
| 526 | const held = seats.get(key); | |
| 527 | if (held === undefined) return; | |
| 528 | seats.delete(key); | |
| 529 | for (const ws of held) if (ws !== taking) close(ws, CLOSE.replaced, reason); | |
| 530 | } | |
| 531 | ||
| 532 | /** | |
| 533 | * One sentence, to one socket. The shape is the wire's, not this module's: | |
| 534 | * `encodeSentence` in `protocol.ts` says text is a sentence and bytes are a | |
| 535 | * frame, and `decodeMessage` is what the canvas, the terminal and any other | |
| 536 | * client read it with. The words are the record's, from `src/concept.ts`. | |
| 537 | */ | |
| 538 | function tell(ws: Sock, sentence: string): void { | |
| 539 | // Encoded outside the try: a sentence over the wire's cap is our bug and | |
| 540 | // throws, and it is not a broken peer to be quietly closed. | |
| 541 | const words = encodeSentence(sentence); | |
| 542 | try { | |
| 543 | ws.send(words); | |
| 544 | } catch { | |
| 545 | close(ws, CLOSE.broken, "send failed"); | |
| 546 | } | |
| 547 | } | |
| 548 | ||
| 405 | 549 | /** Closing never throws: a socket that has already gone is gone. */ |
| 406 | 550 | function close(ws: Sock, code: number, reason: string): void { |
| 407 | 551 | try { |