@joystyk/rooms 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +147 -2
  3. package/dist/canonical.d.ts +10 -0
  4. package/dist/canonical.js +30 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/core.d.ts +3 -0
  7. package/dist/core.js +1879 -0
  8. package/dist/core.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +7 -0
  11. package/dist/names.d.ts +19 -0
  12. package/dist/names.js +28 -0
  13. package/dist/names.js.map +1 -0
  14. package/dist/node/host.d.ts +67 -0
  15. package/dist/node/host.js +323 -0
  16. package/dist/node/host.js.map +1 -0
  17. package/dist/node/index.d.ts +6 -0
  18. package/dist/node/index.js +7 -0
  19. package/dist/node/jobs.d.ts +48 -0
  20. package/dist/node/jobs.js +176 -0
  21. package/dist/node/jobs.js.map +1 -0
  22. package/dist/node/registry.d.ts +32 -0
  23. package/dist/node/registry.js +43 -0
  24. package/dist/node/registry.js.map +1 -0
  25. package/dist/node/server.d.ts +59 -0
  26. package/dist/node/server.js +577 -0
  27. package/dist/node/server.js.map +1 -0
  28. package/dist/node/store.d.ts +53 -0
  29. package/dist/node/store.js +245 -0
  30. package/dist/node/store.js.map +1 -0
  31. package/dist/node/support.d.ts +67 -0
  32. package/dist/node/support.js +126 -0
  33. package/dist/node/support.js.map +1 -0
  34. package/dist/policy.d.ts +15 -0
  35. package/dist/policy.js +41 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/protocol/index.d.ts +43 -0
  38. package/dist/protocol/index.js +48 -0
  39. package/dist/protocol/index.js.map +1 -0
  40. package/dist/protocol/schemas.d.ts +792 -0
  41. package/dist/protocol/schemas.js +332 -0
  42. package/dist/protocol/schemas.js.map +1 -0
  43. package/dist/protocol/types.d.ts +279 -0
  44. package/dist/protocol/types.js +1 -0
  45. package/dist/record.d.ts +173 -0
  46. package/dist/record.js +181 -0
  47. package/dist/record.js.map +1 -0
  48. package/dist/types.d.ts +374 -0
  49. package/dist/types.js +15 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +56 -11
@@ -0,0 +1,43 @@
1
+ import { createRoomCore } from "../core.js";
2
+ //#region src/node/registry.ts
3
+ /**
4
+ * Builds a registry. For each game, the entry with the highest rules version is current;
5
+ * the others are frozen engines kept for rooms already playing on them.
6
+ */
7
+ var createEngineRegistry = (entries, rng, limits) => {
8
+ const cores = /* @__PURE__ */ new Map();
9
+ const current = /* @__PURE__ */ new Map();
10
+ const jobs = /* @__PURE__ */ new Map();
11
+ for (const entry of entries) {
12
+ const key = `${entry.pin.id}@${entry.pin.rulesVersion}`;
13
+ if (cores.has(key)) throw new Error(`${key} is registered twice.`);
14
+ const core = createRoomCore({
15
+ engine: entry.engine,
16
+ pin: entry.pin,
17
+ rng,
18
+ heavyAi: entry.heavyAi === true,
19
+ ...limits ? { limits } : {}
20
+ });
21
+ cores.set(key, core);
22
+ const newest = current.get(entry.pin.id);
23
+ if (newest === void 0 || newest.pin.rulesVersion < entry.pin.rulesVersion) current.set(entry.pin.id, {
24
+ core,
25
+ pin: entry.pin,
26
+ policy: entry.policy
27
+ });
28
+ for (const [kind, url] of Object.entries(entry.jobs ?? {})) jobs.set(`${entry.pin.id}@${entry.pin.rulesVersion}/${kind}`, url);
29
+ }
30
+ return {
31
+ core: (gameId, rulesVersion) => cores.get(`${gameId}@${rulesVersion}`) ?? null,
32
+ current: (gameId) => current.get(gameId) ?? null,
33
+ games: () => [...current.keys()],
34
+ jobModule: (gameId, kind, rulesVersion) => {
35
+ const version = rulesVersion ?? current.get(gameId)?.pin.rulesVersion;
36
+ return version === void 0 ? null : jobs.get(`${gameId}@${version}/${kind}`) ?? null;
37
+ }
38
+ };
39
+ };
40
+ //#endregion
41
+ export { createEngineRegistry };
42
+
43
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","names":[],"sources":["../../src/node/registry.ts"],"sourcesContent":["// The engine registry (room-server.md sections 4.1 and 6.1): which games and rules versions\n// this build carries. A room pins its rules version for a match; frozen engines stay\n// registered until no live room needs them.\nimport type { AnyDiscreteEngine, Rng, RngState } from '@joystyk/contracts';\nimport { createRoomCore } from '../core.ts';\nimport type { CoreLimits, GamePin, RoomCore, RoomPolicy } from '../types.ts';\n\nexport type RegisteredEngine = {\n readonly pin: GamePin;\n readonly engine: AnyDiscreteEngine;\n readonly policy: RoomPolicy;\n /** The manifest's `ai.heavy`: choose on the worker pool with the `ai` job module. */\n readonly heavyAi?: boolean;\n /** Worker entry modules by job kind (a game's `jobs/<kind>.worker.ts`; `ai` for heavy AI). */\n readonly jobs?: { readonly [kind: string]: URL };\n};\n\nexport type CurrentEngine = { readonly core: RoomCore; readonly pin: GamePin; readonly policy: RoomPolicy };\n\nexport interface EngineRegistry {\n /** The core for a game at a rules version, or null if this build no longer carries it. */\n core(gameId: string, rulesVersion: number): RoomCore | null;\n /** The newest engine for a game: new rooms and rematches use it. */\n current(gameId: string): CurrentEngine | null;\n games(): readonly string[];\n /** Pinned room work uses its rules version; unversioned probes select current. */\n jobModule(gameId: string, kind: string, rulesVersion?: number): URL | null;\n}\n\n/**\n * Builds a registry. For each game, the entry with the highest rules version is current;\n * the others are frozen engines kept for rooms already playing on them.\n */\nexport const createEngineRegistry = (\n entries: readonly RegisteredEngine[],\n rng: (state: RngState) => Rng,\n limits?: Partial<CoreLimits>,\n): EngineRegistry => {\n const cores = new Map<string, RoomCore>();\n const current = new Map<string, CurrentEngine>();\n const jobs = new Map<string, URL>();\n for (const entry of entries) {\n const key = `${entry.pin.id}@${entry.pin.rulesVersion}`;\n if (cores.has(key)) throw new Error(`${key} is registered twice.`);\n const core = createRoomCore({\n engine: entry.engine,\n pin: entry.pin,\n rng,\n heavyAi: entry.heavyAi === true,\n ...(limits ? { limits } : {}),\n });\n cores.set(key, core);\n const newest = current.get(entry.pin.id);\n if (newest === undefined || newest.pin.rulesVersion < entry.pin.rulesVersion)\n current.set(entry.pin.id, { core, pin: entry.pin, policy: entry.policy });\n for (const [kind, url] of Object.entries(entry.jobs ?? {}))\n jobs.set(`${entry.pin.id}@${entry.pin.rulesVersion}/${kind}`, url);\n }\n return {\n core: (gameId, rulesVersion) => cores.get(`${gameId}@${rulesVersion}`) ?? null,\n current: (gameId) => current.get(gameId) ?? null,\n games: () => [...current.keys()],\n jobModule: (gameId, kind, rulesVersion) => {\n const version = rulesVersion ?? current.get(gameId)?.pin.rulesVersion;\n return version === undefined ? null : (jobs.get(`${gameId}@${version}/${kind}`) ?? null);\n },\n };\n};\n"],"mappings":";;;;;;AAiCA,IAAa,wBACX,SACA,KACA,WACmB;CACnB,MAAM,wBAAQ,IAAI,IAAsB;CACxC,MAAM,0BAAU,IAAI,IAA2B;CAC/C,MAAM,uBAAO,IAAI,IAAiB;CAClC,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,MAAM,GAAG,MAAM,IAAI,GAAG,GAAG,MAAM,IAAI;EACzC,IAAI,MAAM,IAAI,GAAG,GAAG,MAAM,IAAI,MAAM,GAAG,IAAI,sBAAsB;EACjE,MAAM,OAAO,eAAe;GAC1B,QAAQ,MAAM;GACd,KAAK,MAAM;GACX;GACA,SAAS,MAAM,YAAY;GAC3B,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAC7B,CAAC;EACD,MAAM,IAAI,KAAK,IAAI;EACnB,MAAM,SAAS,QAAQ,IAAI,MAAM,IAAI,EAAE;EACvC,IAAI,WAAW,KAAA,KAAa,OAAO,IAAI,eAAe,MAAM,IAAI,cAC9D,QAAQ,IAAI,MAAM,IAAI,IAAI;GAAE;GAAM,KAAK,MAAM;GAAK,QAAQ,MAAM;EAAO,CAAC;EAC1E,KAAK,MAAM,CAAC,MAAM,QAAQ,OAAO,QAAQ,MAAM,QAAQ,CAAC,CAAC,GACvD,KAAK,IAAI,GAAG,MAAM,IAAI,GAAG,GAAG,MAAM,IAAI,aAAa,GAAG,QAAQ,GAAG;CACrE;CACA,OAAO;EACL,OAAO,QAAQ,iBAAiB,MAAM,IAAI,GAAG,OAAO,GAAG,cAAc,KAAK;EAC1E,UAAU,WAAW,QAAQ,IAAI,MAAM,KAAK;EAC5C,aAAa,CAAC,GAAG,QAAQ,KAAK,CAAC;EAC/B,YAAY,QAAQ,MAAM,iBAAiB;GACzC,MAAM,UAAU,gBAAgB,QAAQ,IAAI,MAAM,CAAC,EAAE,IAAI;GACzD,OAAO,YAAY,KAAA,IAAY,OAAQ,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,KAAK;EACrF;CACF;AACF"}
@@ -0,0 +1,59 @@
1
+ import { type Server } from 'node:http';
2
+ import type { MatchSeeds } from '../types.js';
3
+ import { RoomHost } from './host.js';
4
+ import { type JobPool } from './jobs.js';
5
+ import type { EngineRegistry } from './registry.js';
6
+ import { type RoomStore } from './store.js';
7
+ import { type Clock, type Logger } from './support.js';
8
+ export type ServerLimits = {
9
+ /** Incoming message size in bytes (`ws` maxPayload). */
10
+ readonly maxPayload: number;
11
+ readonly messagesPerSecond: number;
12
+ readonly messageBurst: number;
13
+ /** Over-limit messages tolerated before closing with 4008. */
14
+ readonly rateStrikes: number;
15
+ /** Only over-limit messages within this rolling window count toward closing. */
16
+ readonly rateStrikeWindowMs: number;
17
+ readonly roomsPerIpPerHour: number;
18
+ readonly joinMissesPerIpPer10Min: number;
19
+ readonly connectionsPerIp: number;
20
+ readonly maxConnections: number;
21
+ readonly maxRooms: number;
22
+ readonly outgoingBufferBytes: number;
23
+ readonly heartbeatMs: number;
24
+ readonly idleUnloadMs: number;
25
+ readonly maintenanceMs: number;
26
+ readonly createBodyBytes: number;
27
+ readonly drainJobsMs: number;
28
+ readonly persistRetryMs: number;
29
+ /** How long a heavy AI may choose on the pool before its seat times out. */
30
+ readonly aiTimeoutMs: number;
31
+ };
32
+ export declare const DEFAULT_SERVER_LIMITS: ServerLimits;
33
+ export type RoomServerOptions = {
34
+ readonly store: RoomStore;
35
+ readonly registry: EngineRegistry;
36
+ /** Fresh engine and AI seeds from the CSPRNG, in the rng algorithm the registry's cores use. */
37
+ readonly seeds: () => MatchSeeds;
38
+ readonly pool?: JobPool;
39
+ readonly clock?: Clock;
40
+ readonly log?: Logger;
41
+ /** Exactly this origin may create rooms and open sockets. Null allows any (development only). */
42
+ readonly publicOrigin?: string | null;
43
+ /** Extra origins allowed in development, such as the Vite dev server. */
44
+ readonly devOrigins?: readonly string[];
45
+ /** Traefik's address or CIDR range; only it may report the client address. */
46
+ readonly trustedProxy?: string | null;
47
+ /** The header Traefik reports the client address in; otherwise the last X-Forwarded-For entry. */
48
+ readonly clientIpHeader?: string | null;
49
+ readonly limits?: Partial<ServerLimits>;
50
+ };
51
+ export interface RoomServer {
52
+ readonly http: Server;
53
+ readonly host: RoomHost;
54
+ /** Listens and resumes rooms with outstanding jobs. Resolves with the bound port. */
55
+ listen(port: number, hostname?: string): Promise<number>;
56
+ /** SIGTERM: refuse new work, let jobs finish, tell clients to reconnect, close the store. */
57
+ drain(): Promise<void>;
58
+ }
59
+ export declare const createRoomServer: (options: RoomServerOptions) => RoomServer;
@@ -0,0 +1,577 @@
1
+ import { nameReplacedMessage } from "../names.js";
2
+ import { ClientMessageSchema, CreateRoomRequestSchema, RoomCode, SUPPORTED_PROTOCOLS } from "../protocol/schemas.js";
3
+ import { CloseCode, RejectionCode, isSupportedProtocol } from "../protocol/index.js";
4
+ import { expiresAt } from "./store.js";
5
+ import { RoomHost } from "./host.js";
6
+ import { inlineJobPool } from "./jobs.js";
7
+ import { TokenBucket, WindowCounter, clientAddress, createLogger, hashToken, newRoomCode, newSeatToken, parseTrustedProxy, systemClock } from "./support.js";
8
+ import { checkDisplayName } from "@joystyk/identity/names";
9
+ import { createServer } from "node:http";
10
+ import { rateLimitKey } from "@joystyk/ops";
11
+ import { WebSocket, WebSocketServer } from "ws";
12
+ //#region src/node/server.ts
13
+ var DEFAULT_SERVER_LIMITS = {
14
+ maxPayload: 2048,
15
+ messagesPerSecond: 10,
16
+ messageBurst: 20,
17
+ rateStrikes: 20,
18
+ rateStrikeWindowMs: 6e4,
19
+ roomsPerIpPerHour: 20,
20
+ joinMissesPerIpPer10Min: 20,
21
+ connectionsPerIp: 16,
22
+ maxConnections: 2e3,
23
+ maxRooms: 1e3,
24
+ outgoingBufferBytes: 262144,
25
+ heartbeatMs: 25e3,
26
+ idleUnloadMs: 6e5,
27
+ maintenanceMs: 6e4,
28
+ createBodyBytes: 4096,
29
+ drainJobsMs: 1e4,
30
+ persistRetryMs: 5e3,
31
+ aiTimeoutMs: 3e4
32
+ };
33
+ /** Validates synchronously; schemas that answer with a Promise are refused (they would reorder messages). */
34
+ var validate = (schema, value) => {
35
+ const result = schema["~standard"].validate(value);
36
+ if (result instanceof Promise || result.issues !== void 0) return { ok: false };
37
+ return {
38
+ ok: true,
39
+ value: result.value
40
+ };
41
+ };
42
+ var createRoomServer = (options) => {
43
+ const limits = {
44
+ ...DEFAULT_SERVER_LIMITS,
45
+ ...options.limits
46
+ };
47
+ const clock = options.clock ?? systemClock;
48
+ const log = options.log ?? createLogger();
49
+ const pool = options.pool ?? inlineJobPool(() => ({
50
+ ok: false,
51
+ reason: "failed"
52
+ }));
53
+ const { store, registry } = options;
54
+ const trusted = options.trustedProxy ? parseTrustedProxy(options.trustedProxy) : null;
55
+ const header = options.clientIpHeader ?? null;
56
+ const publicOrigin = options.publicOrigin ?? null;
57
+ const allowedOrigins = /* @__PURE__ */ new Set([...publicOrigin === null ? [] : [publicOrigin], ...options.devOrigins ?? []]);
58
+ let draining = false;
59
+ let connSequence = 0;
60
+ const host = new RoomHost({
61
+ store,
62
+ registry,
63
+ clock,
64
+ pool,
65
+ log,
66
+ maxRooms: limits.maxRooms,
67
+ outgoingBufferBytes: limits.outgoingBufferBytes,
68
+ persistRetryMs: limits.persistRetryMs,
69
+ aiTimeoutMs: limits.aiTimeoutMs,
70
+ draining: () => draining
71
+ });
72
+ const creations = new WindowCounter(36e5, limits.roomsPerIpPerHour);
73
+ const misses = new WindowCounter(6e5, limits.joinMissesPerIpPer10Min);
74
+ const perIp = /* @__PURE__ */ new Map();
75
+ const sockets = new WebSocketServer({
76
+ noServer: true,
77
+ maxPayload: limits.maxPayload,
78
+ perMessageDeflate: { threshold: 1024 }
79
+ });
80
+ const originAllowed = (origin) => {
81
+ if (origin === void 0) return true;
82
+ if (publicOrigin === null) return true;
83
+ return allowedOrigins.has(origin);
84
+ };
85
+ const rateLimited = (limit) => log.write("warn", "rate_limited", { limit });
86
+ const json = (res, status, value, headers = {}) => {
87
+ res.writeHead(status, {
88
+ "content-type": "application/json",
89
+ "cache-control": "no-store",
90
+ ...headers
91
+ });
92
+ res.end(JSON.stringify(value));
93
+ };
94
+ const readBody = (req, max) => new Promise((resolve) => {
95
+ const chunks = [];
96
+ let size = 0;
97
+ let done = false;
98
+ req.on("data", (chunk) => {
99
+ if (done) return;
100
+ size += chunk.length;
101
+ if (size > max) {
102
+ done = true;
103
+ resolve(null);
104
+ req.resume();
105
+ return;
106
+ }
107
+ chunks.push(chunk);
108
+ });
109
+ req.on("end", () => {
110
+ if (!done) resolve(Buffer.concat(chunks).toString("utf8"));
111
+ });
112
+ req.on("error", () => {
113
+ if (!done) resolve(null);
114
+ });
115
+ });
116
+ const createRoom = async (req, res) => {
117
+ if (draining) return json(res, 503, { error: "The server is restarting. Try again in a moment." }, { "retry-after": "5" });
118
+ if (!originAllowed(req.headers.origin)) return json(res, 403, { error: "Origin refused." });
119
+ const body = await readBody(req, limits.createBodyBytes);
120
+ if (body === null) return json(res, 413, { error: "The request is too large." });
121
+ let raw;
122
+ try {
123
+ raw = JSON.parse(body);
124
+ } catch {
125
+ return json(res, 400, { error: "The request is not JSON." });
126
+ }
127
+ const parsed = CreateRoomRequestSchema.safeParse(raw);
128
+ if (!parsed.success) return json(res, 400, { error: "The request is not valid." });
129
+ const request = parsed.data;
130
+ const now = clock.now();
131
+ const ip = rateLimitKey(clientAddress(req, trusted, header));
132
+ if (!creations.allow(ip, now)) {
133
+ rateLimited("rooms-per-ip");
134
+ return json(res, 429, { error: "Too many rooms. Try again later." }, { "retry-after": "600" });
135
+ }
136
+ if (host.rooms.size >= limits.maxRooms) return json(res, 503, { error: "The server is full. Try again soon." }, { "retry-after": "30" });
137
+ const current = registry.current(request.game);
138
+ if (current === null) return json(res, 404, { error: "That game has no online rooms." });
139
+ const config = validate(current.core.engine.schemas.config, request.config ?? {});
140
+ if (!config.ok) return json(res, 400, { error: "That game configuration is not valid." });
141
+ const { decisionMs, bank } = request.settings ?? {};
142
+ let policy = current.policy;
143
+ if (decisionMs !== void 0) policy = {
144
+ ...policy,
145
+ decisionMs,
146
+ ...decisionMs === null ? {} : { bank: null }
147
+ };
148
+ if (bank !== void 0) policy = {
149
+ ...policy,
150
+ bank,
151
+ ...bank === null ? {} : { decisionMs: null }
152
+ };
153
+ let code = newRoomCode();
154
+ while (store.exists(code)) code = newRoomCode();
155
+ const token = newSeatToken();
156
+ const created = current.core.create({
157
+ code,
158
+ policy,
159
+ config: config.value,
160
+ seeds: options.seeds(),
161
+ creator: {
162
+ name: request.name,
163
+ tokenHash: hashToken(token)
164
+ }
165
+ }, now);
166
+ if (!created.ok) {
167
+ if (created.error.diagnostic !== void 0) log.write("error", "room_init_failed", {
168
+ game: current.pin.id,
169
+ ...created.error.diagnostic
170
+ });
171
+ return json(res, 400, { error: created.error.message });
172
+ }
173
+ store.insert(created.value);
174
+ log.write("info", "room_created", {
175
+ room: code,
176
+ game: current.pin.id,
177
+ rules: current.pin.rulesVersion
178
+ });
179
+ const name = created.value.seats[0]?.claim?.name ?? request.name;
180
+ const checked = checkDisplayName(request.name);
181
+ const response = {
182
+ room: code,
183
+ token,
184
+ name,
185
+ ...checked.ok ? {} : { notice: {
186
+ code: "name-replaced",
187
+ message: nameReplacedMessage(checked.error, name)
188
+ } },
189
+ game: current.pin,
190
+ expiresAt: expiresAt(created.value)
191
+ };
192
+ json(res, 201, response);
193
+ };
194
+ const roomInfo = (req, res, code) => {
195
+ const ip = rateLimitKey(clientAddress(req, trusted, header));
196
+ const now = clock.now();
197
+ if (!misses.allow(ip, now, false)) {
198
+ rateLimited("join-misses");
199
+ return json(res, 429, { error: "Too many unknown rooms. Try again later." });
200
+ }
201
+ const record = RoomCode.safeParse(code).success ? host.rooms.get(code)?.room.record ?? store.load(code) : null;
202
+ if (record === null || record.phase === "closed") {
203
+ misses.hit(ip, now);
204
+ return json(res, 404, { error: "No such room." });
205
+ }
206
+ const open = record.phase === "lobby" ? record.seats.filter((s) => s.claim === null && s.controller === "human").length : record.policy.lateJoin === "take-ai-seat" ? record.seats.filter((s) => s.claim === null && s.controller === "ai").length : 0;
207
+ const info = {
208
+ game: record.match?.game ?? record.game,
209
+ phase: record.phase,
210
+ seatsOpen: open,
211
+ spectatorsAllowed: record.policy.maxSpectators > 0
212
+ };
213
+ json(res, 200, info);
214
+ };
215
+ /** The games this build opens rooms for, and its protocol versions. Cheap and public, so it may be cached briefly. */
216
+ const hostedGames = (res) => {
217
+ const hosted = {
218
+ protocol: 1,
219
+ protocols: [...SUPPORTED_PROTOCOLS],
220
+ games: [...registry.games()]
221
+ };
222
+ json(res, 200, hosted, { "cache-control": "public, max-age=60" });
223
+ };
224
+ const http = createServer((req, res) => {
225
+ const path = new URL(req.url ?? "/", "http://rooms.invalid").pathname;
226
+ const route = async () => {
227
+ if (path === "/healthz") return json(res, 200, { ok: true });
228
+ if (path === "/readyz") return draining || !store.ping() ? json(res, 503, { ok: false }) : json(res, 200, { ok: true });
229
+ if (path === "/api/rooms" && req.method === "POST") return createRoom(req, res);
230
+ if (path === "/api/rooms/games" && req.method === "GET") return hostedGames(res);
231
+ const match = /^\/api\/rooms\/([^/]+)$/.exec(path);
232
+ if (match !== null && req.method === "GET") return roomInfo(req, res, match[1] ?? "");
233
+ json(res, 404, { error: "Not found." });
234
+ };
235
+ route().catch((error) => {
236
+ log.write("error", "http_error", {
237
+ path,
238
+ error: String(error)
239
+ });
240
+ if (!res.headersSent) json(res, 500, { error: "The room server could not complete this request." });
241
+ else res.end();
242
+ });
243
+ });
244
+ const refuseUpgrade = (socket, status, extra = "") => {
245
+ socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n${extra}\r\n`);
246
+ };
247
+ http.on("upgrade", (req, socket, head) => {
248
+ if (new URL(req.url ?? "/", "http://rooms.invalid").pathname !== "/ws") return refuseUpgrade(socket, "404 Not Found");
249
+ if (draining) return refuseUpgrade(socket, "503 Service Unavailable", "Retry-After: 5\r\n");
250
+ if (!originAllowed(req.headers.origin)) return refuseUpgrade(socket, "403 Forbidden");
251
+ if (sockets.clients.size >= limits.maxConnections) return refuseUpgrade(socket, "503 Service Unavailable", "Retry-After: 10\r\n");
252
+ const ip = rateLimitKey(clientAddress(req, trusted, header));
253
+ if ((perIp.get(ip) ?? 0) >= limits.connectionsPerIp) {
254
+ rateLimited("connections-per-ip");
255
+ return refuseUpgrade(socket, "429 Too Many Requests");
256
+ }
257
+ sockets.handleUpgrade(req, socket, head, (ws) => accept(ws, ip));
258
+ });
259
+ const notice = (conn, code, message) => {
260
+ conn.send(JSON.stringify({
261
+ v: conn.protocol,
262
+ t: "notice",
263
+ code,
264
+ message
265
+ }));
266
+ };
267
+ /** Connection state per socket, for the heartbeat. */
268
+ const connOf = /* @__PURE__ */ new WeakMap();
269
+ const accept = (ws, ip) => {
270
+ perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
271
+ connSequence += 1;
272
+ const conn = {
273
+ id: `conn-${connSequence}`,
274
+ protocol: 1,
275
+ pendingToken: null,
276
+ socket: ws,
277
+ ip,
278
+ bucket: new TokenBucket(limits.messagesPerSecond, limits.messageBurst, clock.now()),
279
+ room: null,
280
+ strikes: [],
281
+ alive: true,
282
+ get bufferedAmount() {
283
+ return ws.bufferedAmount;
284
+ },
285
+ send: (text) => {
286
+ if (ws.readyState === WebSocket.OPEN) ws.send(text);
287
+ },
288
+ close: (code, reason) => {
289
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(code, reason.slice(0, 120));
290
+ }
291
+ };
292
+ connOf.set(ws, conn);
293
+ ws.on("pong", () => {
294
+ conn.alive = true;
295
+ });
296
+ ws.on("message", (data, isBinary) => onMessage(conn, data, isBinary));
297
+ ws.on("error", () => ws.terminate());
298
+ ws.on("close", () => {
299
+ const count = (perIp.get(ip) ?? 1) - 1;
300
+ if (count <= 0) perIp.delete(ip);
301
+ else perIp.set(ip, count);
302
+ if (conn.room === null) return;
303
+ const live = host.rooms.get(conn.room);
304
+ if (live?.conns.get(conn.id) !== conn) return;
305
+ live.conns.delete(conn.id);
306
+ if (live.conns.size === 0) live.idleSince = clock.now();
307
+ host.dispatch(conn.room, {
308
+ type: "leave",
309
+ conn: conn.id
310
+ });
311
+ });
312
+ };
313
+ const onMessage = (conn, data, isBinary) => {
314
+ const now = clock.now();
315
+ if (!conn.bucket.take(now)) {
316
+ conn.strikes = conn.strikes.filter((at) => at > now - limits.rateStrikeWindowMs);
317
+ conn.strikes.push(now);
318
+ if (conn.strikes.length > limits.rateStrikes) {
319
+ rateLimited("messages");
320
+ conn.close(CloseCode.policy, "too fast");
321
+ } else notice(conn, "too-fast", "Slow down.");
322
+ return;
323
+ }
324
+ if (isBinary) return notice(conn, "bad-message", "Messages are JSON text.");
325
+ let raw;
326
+ try {
327
+ raw = JSON.parse(String(data));
328
+ } catch {
329
+ return notice(conn, "bad-message", "That message is not JSON.");
330
+ }
331
+ const version = typeof raw === "object" && raw !== null ? raw.v : void 0;
332
+ if (typeof version === "number" && !isSupportedProtocol(version)) {
333
+ notice(conn, "protocol-unsupported", "Reload the page to get the latest version.");
334
+ conn.close(CloseCode.policy, "protocol unsupported");
335
+ return;
336
+ }
337
+ const parsed = ClientMessageSchema.safeParse(raw);
338
+ if (!parsed.success) return notice(conn, "bad-message", "That message is not valid.");
339
+ const message = parsed.data;
340
+ if (message.t === "join") return join(conn, message, now);
341
+ const code = conn.room;
342
+ const live = code === null ? void 0 : host.rooms.get(code);
343
+ if (code === null || live === void 0) return notice(conn, "bad-message", "Join a room first.");
344
+ switch (message.t) {
345
+ case "act": {
346
+ const command = validate(live.core.engine.schemas.command, message.command);
347
+ if (!command.ok) {
348
+ conn.send(JSON.stringify({
349
+ v: conn.protocol,
350
+ t: "receipt",
351
+ id: message.id,
352
+ result: {
353
+ status: "rejected",
354
+ code: RejectionCode.badCommand,
355
+ message: "That move is not valid.",
356
+ revision: live.room.record.revision
357
+ }
358
+ }));
359
+ return;
360
+ }
361
+ host.dispatch(code, {
362
+ type: "act",
363
+ conn: conn.id,
364
+ id: message.id,
365
+ expectedRevision: message.rev,
366
+ command: command.value
367
+ });
368
+ return;
369
+ }
370
+ case "control": {
371
+ let control = message.control;
372
+ if (control.kind === "settings" && control.config !== void 0) {
373
+ const config = validate((registry.current(live.room.record.game.id)?.core.engine ?? live.core.engine).schemas.config, control.config);
374
+ if (!config.ok) {
375
+ conn.send(JSON.stringify({
376
+ v: conn.protocol,
377
+ t: "receipt",
378
+ id: message.id,
379
+ result: {
380
+ status: "rejected",
381
+ code: RejectionCode.badCommand,
382
+ message: "Those settings are not valid.",
383
+ revision: live.room.record.revision
384
+ }
385
+ }));
386
+ return;
387
+ }
388
+ control = {
389
+ ...control,
390
+ config: config.value
391
+ };
392
+ }
393
+ host.dispatch(code, {
394
+ type: "control",
395
+ conn: conn.id,
396
+ id: message.id,
397
+ expectedRevision: message.rev,
398
+ control,
399
+ seeds: options.seeds()
400
+ });
401
+ return;
402
+ }
403
+ case "resync":
404
+ host.dispatch(code, {
405
+ type: "resync",
406
+ conn: conn.id
407
+ });
408
+ return;
409
+ }
410
+ };
411
+ const join = (conn, message, now) => {
412
+ if (conn.room !== null) return notice(conn, "bad-message", "This connection already joined a room.");
413
+ conn.protocol = message.v;
414
+ if (!misses.allow(conn.ip, now, false)) {
415
+ rateLimited("join-misses");
416
+ notice(conn, "too-fast", "Too many unknown rooms. Try again later.");
417
+ conn.close(CloseCode.policy, "too many misses");
418
+ return;
419
+ }
420
+ if (draining) {
421
+ conn.send(JSON.stringify({
422
+ v: conn.protocol,
423
+ t: "closing",
424
+ reason: "restart",
425
+ retryAfterMs: 2e3
426
+ }));
427
+ conn.close(CloseCode.restarting, "restarting");
428
+ return;
429
+ }
430
+ let loaded;
431
+ try {
432
+ loaded = host.load(message.room);
433
+ } catch (error) {
434
+ log.write("error", "room_load_failed", {
435
+ room: message.room,
436
+ error: String(error)
437
+ });
438
+ loaded = { problem: "busy" };
439
+ }
440
+ if ("problem" in loaded) {
441
+ if (loaded.problem === "busy") {
442
+ notice(conn, "server-busy", "The server is busy. Try again soon.");
443
+ conn.close(CloseCode.tryAgainLater, "busy");
444
+ } else if (loaded.problem === "engine-missing") {
445
+ conn.send(JSON.stringify({
446
+ v: conn.protocol,
447
+ t: "closing",
448
+ reason: "closed",
449
+ retryAfterMs: null
450
+ }));
451
+ conn.close(CloseCode.noSuchRoom, "closed");
452
+ } else {
453
+ misses.hit(conn.ip, now);
454
+ notice(conn, "no-such-room", "This room has closed.");
455
+ conn.close(CloseCode.noSuchRoom, "no such room");
456
+ }
457
+ return;
458
+ }
459
+ const live = loaded.live;
460
+ if (live.room.record.game.id !== message.game) {
461
+ misses.hit(conn.ip, now);
462
+ notice(conn, "no-such-room", "This room has closed.");
463
+ conn.close(CloseCode.noSuchRoom, "no such room");
464
+ return;
465
+ }
466
+ conn.room = message.room;
467
+ live.conns.set(conn.id, conn);
468
+ const fresh = newSeatToken();
469
+ conn.pendingToken = fresh;
470
+ host.dispatch(message.room, {
471
+ type: "join",
472
+ conn: conn.id,
473
+ name: message.name,
474
+ protocol: message.v,
475
+ presentedTokenHash: message.token === void 0 ? null : hashToken(message.token),
476
+ freshTokenHash: hashToken(fresh),
477
+ watch: message.watch ?? false
478
+ });
479
+ conn.pendingToken = null;
480
+ };
481
+ let heartbeat = null;
482
+ let maintenance = null;
483
+ const startTimers = () => {
484
+ heartbeat = setInterval(() => {
485
+ for (const ws of sockets.clients) {
486
+ const conn = connOf.get(ws);
487
+ if (conn === void 0) continue;
488
+ if (!conn.alive) {
489
+ ws.terminate();
490
+ continue;
491
+ }
492
+ conn.alive = false;
493
+ ws.ping();
494
+ }
495
+ }, limits.heartbeatMs);
496
+ maintenance = setInterval(() => {
497
+ const now = clock.now();
498
+ creations.prune(now);
499
+ misses.prune(now);
500
+ host.sweep(limits.idleUnloadMs);
501
+ try {
502
+ const pruned = store.prune(now);
503
+ if (pruned > 0) log.write("info", "rooms_pruned", { rooms: pruned });
504
+ } catch (error) {
505
+ log.write("error", "prune_failed", { error: String(error) });
506
+ }
507
+ }, limits.maintenanceMs);
508
+ heartbeat.unref();
509
+ maintenance.unref();
510
+ };
511
+ const listen = async (port, hostname = "0.0.0.0") => {
512
+ await new Promise((resolve, reject) => {
513
+ http.once("error", reject);
514
+ http.listen(port, hostname, () => {
515
+ http.off("error", reject);
516
+ resolve();
517
+ });
518
+ });
519
+ startTimers();
520
+ for (const code of store.withPendingJobs()) try {
521
+ host.load(code);
522
+ } catch (error) {
523
+ log.write("error", "room_load_failed", {
524
+ room: code,
525
+ error: String(error)
526
+ });
527
+ }
528
+ const address = http.address();
529
+ const bound = typeof address === "object" && address !== null ? address.port : port;
530
+ log.write("info", "listening", { port: bound });
531
+ return bound;
532
+ };
533
+ const drain = async () => {
534
+ if (draining) return;
535
+ draining = true;
536
+ log.write("info", "drain_started", {
537
+ rooms: host.rooms.size,
538
+ connections: sockets.clients.size
539
+ });
540
+ await pool.idle(limits.drainJobsMs);
541
+ const closing = JSON.stringify({
542
+ v: 1,
543
+ t: "closing",
544
+ reason: "restart",
545
+ retryAfterMs: 2e3
546
+ });
547
+ const open = [...sockets.clients];
548
+ for (const ws of open) {
549
+ if (ws.readyState === WebSocket.OPEN) ws.send(closing);
550
+ ws.close(CloseCode.restarting, "restarting");
551
+ }
552
+ for (const code of [...host.rooms.keys()]) host.unload(code, "drain");
553
+ if (heartbeat !== null) clearInterval(heartbeat);
554
+ if (maintenance !== null) clearInterval(maintenance);
555
+ const closed = Promise.all(open.map((ws) => ws.readyState === WebSocket.CLOSED ? Promise.resolve() : new Promise((resolve) => ws.once("close", () => resolve()))));
556
+ await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 2e3).unref())]);
557
+ for (const ws of sockets.clients) ws.terminate();
558
+ await new Promise((resolve) => sockets.close(() => resolve()));
559
+ await new Promise((resolve) => {
560
+ http.close(() => resolve());
561
+ http.closeAllConnections();
562
+ });
563
+ await pool.close();
564
+ store.close();
565
+ log.write("info", "drain_finished", {});
566
+ };
567
+ return {
568
+ http,
569
+ host,
570
+ listen,
571
+ drain
572
+ };
573
+ };
574
+ //#endregion
575
+ export { DEFAULT_SERVER_LIMITS, createRoomServer };
576
+
577
+ //# sourceMappingURL=server.js.map