@zaisma/game-server 0.0.0-stage → 1.0.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.
package/bin/node.js ADDED
@@ -0,0 +1,615 @@
1
+ #!/usr/bin/env node
2
+ // Run a Zaisma game server on your own Node server (Node 22.18 or later).
3
+ //
4
+ // zaisma-game-server ./server.ts --game my-game --port 8080
5
+ //
6
+ // or from code: import { startServer } from "@zaisma/game-server/node".
7
+ //
8
+ // Players connect to wss://your-server/v1/rooms/<game>/<code>, the same address
9
+ // layout as Zaisma rooms, and prove who they are with Zaisma player passes,
10
+ // checked against the keys Zaisma publishes. Put it behind HTTPS (Caddy, nginx,
11
+ // a Cloudflare Tunnel) and add the address to your game's own servers (own_server_urls, or zaisma own-servers).
12
+ // The game's requests (onRequest) come to POST /v1/requests/<game> on the same port, and its
13
+ // schedules (onSchedule) run while it's up, at the minutes their crons give in UTC. A Zaisma webhook
14
+ // whose endpoint_url is https://your-server/v1/webhooks reaches onWebhook, checked with its signing
15
+ // secret in the ZAISMA_WEBHOOK_SIGNING_SECRET environment variable.
16
+ // The game's database (room.database) is a local SQLite file, --database-file, made on first use.
17
+ // Its rooms' states are another, --room-state-file, so a restarted server brings its matches back (onRestored).
18
+ // Its secrets (room.secrets, server.secrets) are this process's environment variables, and it calls
19
+ // other servers with plain fetch(), with no list: on your own server nothing stands in between.
20
+ import { WebSocketServer } from "ws";
21
+ import { createServer } from "node:http";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
+ import { resolve } from "node:path";
24
+ import { realpathSync } from "node:fs";
25
+ import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, clientIdle, defineGameServer, errorMessage, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
26
+ import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
27
+
28
+ const HELLO_MS = 5000;
29
+ /**
30
+ * How often rooms check on their players (idle, hidden, held seats, players whose time to reconnect ran
31
+ * out), and drop connections that stopped answering. Rooms that tick also check reconnects every tick.
32
+ */
33
+ const SWEEP_MS = 5 * 1000;
34
+ /** A connection with no ping or message for this long is dropped. Games ping every 25 seconds (once a minute while hidden). */
35
+ const DEAD_MS = 75 * 1000;
36
+ /** The most of a request read: the request, its pass and the JSON around them. */
37
+ const MAXIMUM_REQUEST_BODY_SIZE_BYTES = REQUEST_LIMITS.maximumRequestSizeBytes + REQUEST_LIMITS.maximumPassSizeBytes + 1024;
38
+ /** The most of one WebSocket message: a hello with the fullest pass and joinData at its limit, with room for its other fields. Messages after it are held to ROOM_LIMITS.maximumGameMessageSizeBytes. */
39
+ const MAXIMUM_SOCKET_MESSAGE_SIZE_BYTES = REQUEST_LIMITS.maximumPassSizeBytes + ROOM_LIMITS.maximumJoinDataSizeBytes + 4 * 1024;
40
+ /** A reserved room nobody comes to is dropped after this. */
41
+ const RESERVED_MS = 10 * 60 * 1000;
42
+ /** A room that took players from another (room.moveTo) waits this long for them, like its held seats. */
43
+ const SEAT_MS = 60 * 1000;
44
+ /** A room that empties keeps its saved state this long, like Zaisma's rooms, so players coming back find it as it was. A kept room (room.setKept) keeps it until it ends. */
45
+ const KEEP_EMPTY_MS = 24 * 3600 * 1000;
46
+ /** How often saved states of rooms that emptied over a day ago are deleted. */
47
+ const ROOM_STATE_SWEEP_MS = 3600 * 1000;
48
+ /** A player whose socket holds this much not yet sent is backed up: state sync skips them, and latest-only messages wait. */
49
+ const BACKED_UP_SIZE_BYTES = ROOM_TUNING.backedUpSizeBytes;
50
+ /** How often rooms with something waiting for a backed-up player check whether they caught up. */
51
+ const CATCH_UP_MS = 50;
52
+
53
+ /**
54
+ * Start a server. Options (all closed: an unknown one is a GameServerError unknown_option, one of the
55
+ * wrong kind a bad_option, before anything listens): gameServer (a game server object) and gameSlug
56
+ * (its Zaisma slug) are required. port (8080), origins (allowed page origins, by default
57
+ * https://<gameSlug>.zaismausercontent.com; none allows any page), issuerUrl (who signs the passes; Zaisma's),
58
+ * loadPassKeys (async (isRefreshing) => the pass keys; by default from <issuerUrl>/.well-known/jwks.json),
59
+ * loadReceiptKeys (the same for room.verifyReceipt and server.verifyReceipt; by default from
60
+ * <issuerUrl>/.well-known/receipt-jwks.json), loadProfile (async (playerId) => an account's public profile or null),
61
+ * database (room.database and server.database; by default a local file, zaisma-database.sqlite, made the
62
+ * first time the game uses it), secrets (room.secrets and server.secrets; by default this process's
63
+ * environment variables), webhookSigningSecret (the whsec_ secret of the webhook whose events come to
64
+ * /v1/webhooks; by default the ZAISMA_WEBHOOK_SIGNING_SECRET environment variable) and roomStateFile (where
65
+ * rooms save their states, so a restart brings matches back; by default zaisma-room-states.sqlite, null to keep none).
66
+ * Answers { close(), httpServer }: close() saves every room first and finishes once the server has stopped.
67
+ */
68
+ export function startServer(options) {
69
+ const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite" } = checkedStartServerOptions(options, PASS_ISSUER_URL);
70
+ const definition = defineGameServer(gameServer);
71
+ const roomStates = roomStateFile === null ? null : localRoomStates(roomStateFile);
72
+ const stateSaveIntervalMs = (definition.stateSaveIntervalSec ?? ROOM_LIMITS.defaultStateSaveIntervalSec) * 1000;
73
+ // Schedules run while this server is up; one that's down when a run is due misses that run.
74
+ const schedules = startSchedules(definition, { database, secrets, verifyReceipt: receiptClaims }, (run, failure) => console.error(`Game server error in onSchedule ("${run.name}", due ${run.scheduledAt}):`, failure));
75
+ const rooms = new Map(); // code -> { core, sockets: Map<playerId, ws>, timer }
76
+ const httpServer = createServer((request, response) => answerHttp(request, response));
77
+ const wss = new WebSocketServer({ server: httpServer, maxPayload: MAXIMUM_SOCKET_MESSAGE_SIZE_BYTES });
78
+ httpServer.listen(portNumber);
79
+
80
+ /** room.verifyReceipt and server.verifyReceipt: a receipt for this game from Zaisma's receipt issuer, checked against its keys (fetched again once when its key is new). */
81
+ async function receiptClaims(receipt) {
82
+ const expect = { gameSlug, issuerUrl: `${issuerUrl}/receipts` };
83
+ return verifyReceipt(receipt, await loadReceiptKeys(), expect).catch(async (error) => {
84
+ if (error instanceof ReceiptError && error.code === "unknown_key") return verifyReceipt(receipt, await loadReceiptKeys(true), expect);
85
+ throw error;
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Who a pass is: checked against Zaisma's keys (fetched again once when its key is new), with an
91
+ * account's name from its public profile, and the pass's claims, for blocks between players. Null
92
+ * for an account deleted since; PassError for a bad pass.
93
+ */
94
+ async function playerFrom(pass) {
95
+ const claims = await verifyPass(pass, await loadPassKeys(), { gameSlug, issuerUrl }).catch(async (error) => {
96
+ // A new key may have been published since we cached them.
97
+ if (error instanceof PassError && error.code === "unknown_key") return verifyPass(pass, await loadPassKeys(true), { gameSlug, issuerUrl });
98
+ throw error;
99
+ });
100
+ // An account's pass is proof only: its name comes from its public profile, and a deleted account's pass stops working.
101
+ const name = claims.kind === "account" ? (await loadProfile(claims.sub))?.name : claims.name;
102
+ // What the account owns here rides in its pass, as on Zaisma rooms (player.ownedItemNames).
103
+ const owned = claims.owned_item_names?.length ? { ownedItemNames: claims.owned_item_names } : {};
104
+ // A kind added after v1 plays as a guest here, as in Zaisma rooms.
105
+ return name ? { player: { id: claims.sub, name, kind: claims.kind === "account" ? "account" : "guest", ...owned }, claims } : null;
106
+ }
107
+
108
+ // Requests from the game to onRequest, outside any room: POST /v1/requests/<game> with { pass, request },
109
+ // sent as text/plain JSON so browsers need no preflight. Answers { answer } or { error: { code, message } }.
110
+ function answerHttp(request, response) {
111
+ if (request.url === "/v1/webhooks") return answerWebhook(request, response);
112
+ const origin = request.headers.origin;
113
+ const isAllowed = !origins.length || origins.includes(origin);
114
+ const headers = { "Content-Type": "application/json", "Cache-Control": "no-store", ...(isAllowed && origin ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" } : {}) };
115
+ const reply = (httpStatus, body) => {
116
+ response.writeHead(httpStatus, headers);
117
+ response.end(JSON.stringify(body));
118
+ };
119
+ const fail = (code, message) => reply(REQUEST_ERROR_HTTP_STATUS[code], requestError(code, message));
120
+ if (request.method === "OPTIONS") {
121
+ response.writeHead(204, { ...headers, "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "Content-Type" });
122
+ return response.end();
123
+ }
124
+ if (request.method !== "POST" || request.url !== `/v1/requests/${gameSlug}`) {
125
+ response.writeHead(404, { "Content-Type": "text/plain" });
126
+ return response.end("Not found");
127
+ }
128
+ if (!isAllowed) return fail("bad_origin");
129
+ let text = "";
130
+ let isTooBig = false;
131
+ request.setEncoding("utf8");
132
+ request.on("data", (chunk) => {
133
+ if (isTooBig) return;
134
+ text += chunk;
135
+ if (text.length > MAXIMUM_REQUEST_BODY_SIZE_BYTES) isTooBig = true;
136
+ });
137
+ request.on("end", async () => {
138
+ try {
139
+ if (isTooBig) return fail("too_big");
140
+ const body = readRequestBody(text);
141
+ if ("code" in body) return fail(body.code);
142
+ let checked;
143
+ try {
144
+ checked = await playerFrom(body.pass);
145
+ } catch (error) {
146
+ if (error instanceof PassError) return fail("bad_pass");
147
+ throw error;
148
+ }
149
+ if (!checked) return fail("bad_pass");
150
+ const outcome = await answerRequest(definition, checked.player, body.request, { database, secrets, verifyReceipt: receiptClaims });
151
+ if ("answer" in outcome) return reply(200, { answer: outcome.answer });
152
+ if (outcome.code === "game_server_error") console.error("Game server error in onRequest:", outcome.failure);
153
+ fail(outcome.code);
154
+ } catch (error) {
155
+ console.error("Couldn't answer a request:", error);
156
+ fail("busy");
157
+ }
158
+ });
159
+ }
160
+
161
+ // Events from a Zaisma webhook whose endpoint_url is this server's /v1/webhooks: signed the Standard
162
+ // Webhooks way, checked with its signing secret, then handed to onWebhook. An event about another game
163
+ // (one webhook can take several) is skipped with 204, before anything else, so it never erases this
164
+ // game's rows. An account_deleted event about this game first erases the player from the local
165
+ // database when its account_deletion says deleted_account_erase_enabled, the same switch Zaisma follows
166
+ // for its own databases. Answers 204 when that and onWebhook finished, so Zaisma counts it delivered;
167
+ // anything else is tried again later.
168
+ function answerWebhook(request, response) {
169
+ const reply = (httpStatus, message) => {
170
+ response.writeHead(httpStatus, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
171
+ response.end(message);
172
+ };
173
+ if (request.method !== "POST") return reply(405, "Webhooks are POSTed.");
174
+ if (!webhookSigningSecret) return reply(503, "Set ZAISMA_WEBHOOK_SIGNING_SECRET to the webhook's signing_secret to take its events.");
175
+ const header = (name) => (typeof request.headers[name] === "string" ? request.headers[name] : null);
176
+ const headers = { webhookId: header("webhook-id"), webhookTimestamp: header("webhook-timestamp"), webhookSignature: header("webhook-signature") };
177
+ // A POST with no headers, or an old timestamp, is turned away before its body is read.
178
+ const refuse = (httpStatus, error) => {
179
+ response.once("finish", () => request.destroy());
180
+ reply(httpStatus, `${error.message} ${error.fix}`);
181
+ };
182
+ const chunks = [];
183
+ let sizeBytes = 0;
184
+ let isAnswered = false;
185
+ verifyWebhook(webhookSigningSecret, headers, "").then((early) => {
186
+ if ("error" in early && early.error.code !== "bad_signature" && !isAnswered && !response.headersSent) {
187
+ isAnswered = true;
188
+ return refuse(401, early.error);
189
+ }
190
+ });
191
+ request.on("data", (chunk) => {
192
+ if (isAnswered) return;
193
+ sizeBytes += chunk.length;
194
+ if (sizeBytes <= WEBHOOK_LIMITS.maximumEventSizeBytes) return void chunks.push(chunk);
195
+ isAnswered = true;
196
+ refuse(413, { message: `The event is over ${WEBHOOK_LIMITS.maximumEventSizeBytes} bytes, more than any event Zaisma sends.`, fix: "Only take POSTs Zaisma's webhooks send." });
197
+ });
198
+ request.on("end", async () => {
199
+ try {
200
+ if (isAnswered) return;
201
+ const text = Buffer.concat(chunks).toString("utf8");
202
+ const checked = await verifyWebhook(webhookSigningSecret, headers, text);
203
+ if (isAnswered) return;
204
+ if ("error" in checked) return reply(401, `${checked.error.message} ${checked.error.fix}`);
205
+ const event = checked.event;
206
+ if (event.game && event.game.slug !== gameSlug) return reply(204, "");
207
+ const isAccountDeleted = event.kind === "account_deleted";
208
+ const deletion = event.account_deletion;
209
+ if (isAccountDeleted && event.game?.slug === gameSlug && deletion?.deleted_account_erase_enabled === true) {
210
+ try {
211
+ await database.erasePlayer(deletion.player_id);
212
+ } catch (error) {
213
+ console.error(`Couldn't erase ${deletion.player_id} from the game database (${event.id}):`, error);
214
+ return reply(500, "Erasing the player failed; Zaisma tries the event again later.");
215
+ }
216
+ }
217
+ if (typeof definition.onWebhook !== "function") return isAccountDeleted ? reply(204, "") : reply(404, "This game server has no onWebhook.");
218
+ const outcome = await runWebhook(definition, event, { database, secrets, verifyReceipt: receiptClaims });
219
+ if ("error" in outcome) {
220
+ console.error(`Game server error in onWebhook (${checked.event.kind}, ${checked.event.id}):`, outcome.error.message);
221
+ return reply(500, "onWebhook failed; Zaisma tries the event again later.");
222
+ }
223
+ reply(204, "");
224
+ } catch (error) {
225
+ console.error("Couldn't take a webhook:", error);
226
+ reply(500, "Couldn't take the event.");
227
+ }
228
+ });
229
+ }
230
+
231
+ // start: how a reserved room begins (room.reserve). It's made before its players hear the code,
232
+ // closed to everyone but them. A room with a saved state (a restart) comes back with it instead.
233
+ function roomFor(code, start) {
234
+ let room = rooms.get(code);
235
+ if (room) return room;
236
+ const saved = start ? null : readSaved(code);
237
+ const sockets = new Map();
238
+ const core = new RoomCore(
239
+ definition,
240
+ {
241
+ roomCode: code,
242
+ database,
243
+ secrets,
244
+ verifyReceipt: receiptClaims,
245
+ send: (id, text) => sockets.get(id)?.send(text),
246
+ sendBytes: (id, frame) => sockets.get(id)?.send(frame, { binary: true }),
247
+ isBackedUp: (id) => (sockets.get(id)?.bufferedAmount ?? 0) >= BACKED_UP_SIZE_BYTES,
248
+ close: (id, code, reason) => {
249
+ const ws = sockets.get(id);
250
+ ws?.close(code, reason);
251
+ // A connection too far behind: its close frame would wait behind the backlog it can't read, so free it now (the game sees 1006 and reconnects).
252
+ if (code === NO_ANSWER_CLOSE_CODE) ws?.terminate();
253
+ },
254
+ reserve: (next, how) => {
255
+ // It waits for its players, then goes like any room once it's empty. It knows where it came from (awayMode "lobby").
256
+ wait(next, roomFor(next, { ...how, lobbyRoomCode: code }), RESERVED_MS);
257
+ for (const id of how.playerIds) sockets.get(id)?.send(JSON.stringify({ type: "move", roomCode: next }));
258
+ },
259
+ // Another room here takes the players it wants; they're told to go, and this room hears who went.
260
+ moveTo: (next, ask) => {
261
+ const target = rooms.get(next);
262
+ const answer = target && next !== code
263
+ ? target.core.expect({ ...ask, fromRoomCode: code })
264
+ : Promise.resolve({ movedPlayerIds: [], refusals: ask.players.map((player) => ({ playerId: player.id, code: "room_not_running", message: ERROR_TEXT.room_not_running })) });
265
+ answer
266
+ .then((moveAnswer) => {
267
+ if (target && moveAnswer.movedPlayerIds.length) wait(next, target, SEAT_MS);
268
+ for (const id of moveAnswer.movedPlayerIds) sockets.get(id)?.send(JSON.stringify({ type: "move", roomCode: next }));
269
+ core.moved(next, moveAnswer, ask.players);
270
+ })
271
+ .catch((error) => console.error("Couldn't move players:", error));
272
+ },
273
+ },
274
+ // As in Zaisma's rooms: a room that ticks comes back with its state and onRestored, its match's players away until they
275
+ // reconnect; a turn-based room comes back with its state and its room settings alone.
276
+ saved ? { state: saved.state, room: saved.savedRoom, ...(definition.ticksPerSec ? { restoreInfo: restoreInfoOf(saved.savedAtMs, Date.now(), null, saved.isSavedEmpty), lostPlayers: saved.matchPlayers } : {}) } : { start },
277
+ );
278
+ if (saved) core.savedAtMs = saved.savedAtMs;
279
+ // passes: each player's checked pass, newest, for the blocks and friendships between them (passesHaveBlockBetween, passesAreFriends).
280
+ // savedStateKey: what the room last saved (its state and its match's players), so an unchanged one isn't written again.
281
+ room = { core, sockets, passes: new Map(), timer: null, waiting: false, savedStateKey: saved ? stateKeyOf(saved.stateJson, saved.matchPlayers, saved.savedRoom.isKept === true) : null, hasReportedStateSize: false };
282
+ rooms.set(code, room);
283
+ return room;
284
+ }
285
+
286
+ /** A room's saved state from the file, or null if it has none (or it can't be read). */
287
+ function readSaved(code) {
288
+ try {
289
+ const saved = roomStates?.read(code);
290
+ return saved ? { ...saved, state: JSON.parse(saved.stateJson) } : null;
291
+ } catch (error) {
292
+ console.error(`Couldn't read room ${code}'s saved state:`, error);
293
+ return null;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Save a room's state, the players in its match and the room, when its state changed (or it was kept or
299
+ * unkept). Ended rooms have nothing to come back to. isSavedEmpty: saved as the room emptied.
300
+ */
301
+ function saveRoom(code, room, isSavedEmpty = false) {
302
+ if (!roomStates || room.core.ended) return;
303
+ const nowMs = Date.now();
304
+ room.core.savedAtMs = nowMs;
305
+ let stateJson;
306
+ try {
307
+ stateJson = JSON.stringify(room.core.state);
308
+ } catch (error) {
309
+ return console.error(`Couldn't save room ${code}'s state:`, error);
310
+ }
311
+ const matchPlayers = [...room.core.players.values()];
312
+ const stateKey = stateJson === undefined ? null : stateKeyOf(stateJson, matchPlayers, room.core.isKept);
313
+ if (stateKey === null) return;
314
+ if (stateKey === room.savedStateKey) {
315
+ if (isSavedEmpty) roomStates.markEmpty(code, room.core.saved, matchPlayers);
316
+ return;
317
+ }
318
+ if (Buffer.byteLength(stateJson) > ROOM_LIMITS.maximumSavedStateSizeBytes) {
319
+ if (!room.hasReportedStateSize) console.warn(`Room ${code}'s state is over 32 MB, so it isn't saved and a restart starts it fresh.`);
320
+ room.hasReportedStateSize = true;
321
+ return;
322
+ }
323
+ try {
324
+ roomStates.write(code, { stateJson, matchPlayers, savedRoom: room.core.saved, savedAtMs: nowMs, isSavedEmpty });
325
+ room.savedStateKey = stateKey;
326
+ } catch (error) {
327
+ console.error(`Couldn't save room ${code}'s state:`, error);
328
+ }
329
+ }
330
+
331
+ /** A turn-based room saves after each change, once its game server has finished with it. */
332
+ function keep(code, room) {
333
+ if (definition.ticksPerSec || !roomStates) return;
334
+ void room.core.settled().then(() => {
335
+ if (!room.core.dirty) return;
336
+ room.core.dirty = false;
337
+ saveRoom(code, room);
338
+ });
339
+ }
340
+
341
+ /** A real-time room ticks while someone's playing, saving its state every stateSaveIntervalSec, and stops while everyone's walked away. */
342
+ function pace(code, room) {
343
+ if (!definition.ticksPerSec) return keep(code, room);
344
+ if (room.core.playingCount > 0 && !room.core.ended) {
345
+ if (room.timer) return;
346
+ let lastTickAtMs = Date.now();
347
+ room.core.savedAtMs ??= lastTickAtMs;
348
+ room.timer = setInterval(() => {
349
+ const nowMs = Date.now();
350
+ room.core.tick((nowMs - lastTickAtMs) / 1000);
351
+ lastTickAtMs = nowMs;
352
+ if (nowMs - room.core.savedAtMs >= stateSaveIntervalMs) saveRoom(code, room);
353
+ }, 1000 / definition.ticksPerSec);
354
+ } else if (room.timer) {
355
+ clearInterval(room.timer);
356
+ room.timer = null;
357
+ saveRoom(code, room);
358
+ }
359
+ }
360
+
361
+ /** Keep a room for players on their way, even if it empties meanwhile. */
362
+ function wait(code, room, durationMs) {
363
+ room.waiting = true;
364
+ clearTimeout(room.waitTimer);
365
+ room.waitTimer = setTimeout(() => {
366
+ room.waiting = false;
367
+ closeIfEmpty(code, room);
368
+ }, durationMs);
369
+ room.waitTimer.unref?.();
370
+ }
371
+
372
+ // A room with only spectators left has no match to watch: they're told it ended and go, so it empties.
373
+ function dismissSpectatorsIfAlone(room) {
374
+ if ([...room.sockets.keys()].some((id) => !room.core.spectators.has(id))) return;
375
+ const text = JSON.stringify({ type: "ended", message: "The match ended.", endReason: "no_players" });
376
+ for (const [id, ws] of [...room.sockets]) {
377
+ room.sockets.delete(id);
378
+ room.core.leave(id);
379
+ ws.send(text);
380
+ ws.close(ENDED_CLOSE_CODE, "Match ended");
381
+ }
382
+ }
383
+
384
+ // A room with nobody connected stays while players whose connection dropped have time to come back, and while it holds seats.
385
+ // One that empties keeps its saved state for a day (a kept one until it ends); one that ended keeps none.
386
+ function closeIfEmpty(code, room) {
387
+ if (rooms.get(code) !== room || room.waiting || (!room.core.ended && (room.sockets.size > 0 || room.core.heldSeatCount > 0 || room.core.nextReconnectDeadlineMs() !== null))) return;
388
+ if (room.timer) clearInterval(room.timer);
389
+ rooms.delete(code);
390
+ try {
391
+ if (room.core.ended) roomStates?.remove(code);
392
+ else saveRoom(code, room, true);
393
+ } catch (error) {
394
+ console.error(`Couldn't update room ${code}'s saved state:`, error);
395
+ }
396
+ }
397
+
398
+ // Check on every room's players: connections that stopped answering, idle and hidden players, held seats that ran out.
399
+ let roomStatesSweptAtMs = 0;
400
+ const sweeper = setInterval(() => {
401
+ const nowMs = Date.now();
402
+ for (const [code, room] of rooms) {
403
+ for (const ws of room.sockets.values()) if (nowMs - ws.zaismaSeenAtMs > DEAD_MS) ws.terminate();
404
+ room.core.sweep();
405
+ pace(code, room);
406
+ closeIfEmpty(code, room);
407
+ }
408
+ if (roomStates && nowMs - roomStatesSweptAtMs >= ROOM_STATE_SWEEP_MS) {
409
+ roomStatesSweptAtMs = nowMs;
410
+ try {
411
+ roomStates.removeOlder(nowMs - KEEP_EMPTY_MS, rooms.keys());
412
+ } catch (error) {
413
+ console.error("Couldn't delete old room states:", error);
414
+ }
415
+ }
416
+ }, SWEEP_MS);
417
+ sweeper.unref?.();
418
+
419
+ // Players who were backed up get what waited for them once their socket drains (ws has no event for it).
420
+ const catcher = setInterval(() => {
421
+ for (const room of rooms.values()) if (room.core.isCatchingUp) room.core.catchUp();
422
+ }, CATCH_UP_MS);
423
+ catcher.unref?.();
424
+
425
+ wss.on("connection", (ws, request) => {
426
+ const address = /^\/v1\/rooms\/([a-z0-9-]{1,40})\/([A-Z0-9]{6})$/.exec(request.url ?? "");
427
+ const refuse = (code, message) => {
428
+ ws.send(errorMessage(code, message));
429
+ // Busy is a try-again (1013); every other refusal is final (4001).
430
+ ws.close(code === "busy" ? BUSY_CLOSE_CODE : REMOVED_CLOSE_CODE, code);
431
+ };
432
+ // Refused before the hello too, with a code the SDK reads: a browser never sees why a socket closed otherwise.
433
+ if (!address || address[1] !== gameSlug || !isRoomCode(address[2])) return refuse("game_unavailable");
434
+ if (origins.length && !origins.includes(request.headers.origin)) return refuse("bad_origin");
435
+ if (typeof definition.setup !== "function") return refuse("game_unavailable", "This game server has no setup(), so it runs no rooms: it only answers requests, runs on schedules or takes webhooks.");
436
+ const code = address[2];
437
+ let playerId = null;
438
+ const helloTimer = setTimeout(() => playerId || refuse("no_hello"), HELLO_MS);
439
+ ws.zaismaSeenAtMs = Date.now();
440
+
441
+ ws.on("message", async (received, isBinary) => {
442
+ const text = isBinary ? null : received.toString();
443
+ ws.zaismaSeenAtMs = Date.now();
444
+ // Games ping every 25 seconds or so, so a connection that stops answering can be dropped.
445
+ if (text === "ping") return ws.send("pong");
446
+ if (playerId) {
447
+ const room = rooms.get(code);
448
+ // Bytes (a binary frame) go to the game server's onBytes, under the same limits as messages.
449
+ const errorCode = text === null ? room?.core.receiveBytes(playerId, binaryBytes(received)) : room?.core.receive(playerId, text);
450
+ if (errorCode) ws.send(errorMessage(errorCode));
451
+ if (room) pace(code, room);
452
+ return;
453
+ }
454
+ let pass;
455
+ let made = {};
456
+ // Come to watch, not to play (joinRoom({ isSpectator: true })).
457
+ let isSpectator = false;
458
+ try {
459
+ const hello = JSON.parse(text ?? "");
460
+ if (hello.type !== "hello" || typeof hello.pass !== "string") return refuse("no_hello");
461
+ pass = hello.pass;
462
+ isSpectator = hello.isSpectator === true;
463
+ if (hello.room && typeof hello.room === "object") made = { ...(hello.room.isListed === true ? { isListed: true } : {}), ...(hello.room.friendJoinsEnabled === false ? { friendJoinsEnabled: false } : {}), ...tidyRoomInfo(hello.room) };
464
+ made.clientIdle = clientIdle(hello);
465
+ const checked = checkRoomData(hello.joinData, ROOM_LIMITS.maximumJoinDataSizeBytes);
466
+ if (!checked.isValid) return refuse("too_big");
467
+ made.joinData = checked.checkedData;
468
+ } catch {
469
+ return refuse("no_hello");
470
+ }
471
+ let checked;
472
+ try {
473
+ checked = await playerFrom(pass);
474
+ } catch (error) {
475
+ if (error instanceof PassError) return refuse("bad_pass");
476
+ // Zaisma couldn't be reached for the account's name: the player tries again, nothing crashes.
477
+ console.error("Couldn't check a player's pass:", error);
478
+ return refuse("busy");
479
+ }
480
+ if (!checked) return refuse("bad_pass");
481
+ const { player, claims } = checked;
482
+ clearTimeout(helloTimer);
483
+ // A spectator watches a match: one with nobody in it isn't running, and a spectator never starts one.
484
+ if (isSpectator && !(rooms.get(code)?.core.players.size > 0)) return refuse("room_not_running");
485
+ const room = roomFor(code);
486
+ // The room's rules and the game's canJoin, before anyone hears about this player.
487
+ const admitted = await room.core.admit(player, made.joinData, { isSpectator });
488
+ if (!admitted.isAllowed) {
489
+ closeIfEmpty(code, room);
490
+ return refuse(admitted.code, admitted.message);
491
+ }
492
+ room.sockets.get(player.id)?.close(REMOVED_CLOSE_CODE, "Joined from somewhere else");
493
+ room.sockets.set(player.id, ws);
494
+ // Blocks between accounts come in their passes: this player's and those of everyone the room remembers.
495
+ // Spectators send nothing, so nothing of theirs reaches a player to block.
496
+ if (!isSpectator) {
497
+ room.passes.set(player.id, claims);
498
+ for (const id of room.passes.keys()) if (!room.core.known.has(id) && id !== player.id) room.passes.delete(id);
499
+ room.core.setAccountBlocks(player.id, [...room.passes].filter(([id, other]) => id !== player.id && passesHaveBlockBetween(claims, other)).map(([id]) => id));
500
+ room.core.setFriends(player.id, [...room.passes].filter(([id, other]) => id !== player.id && passesAreFriends(claims, other)).map(([id]) => id));
501
+ }
502
+ // Rooms here aren't in Zaisma's room lists, but listed, open and closed work the same for the game.
503
+ const joined = room.core.join(player, { ...made, isSpectator });
504
+ if (!joined.isAllowed) {
505
+ room.sockets.delete(player.id);
506
+ closeIfEmpty(code, room);
507
+ return refuse(joined.code, joined.message);
508
+ }
509
+ playerId = player.id;
510
+ room.waiting = false;
511
+ pace(code, room);
512
+ });
513
+
514
+ // A frame over the size limit (or a broken one) is an error on this one socket: it closes and the close handler below takes it out, the runner keeps going.
515
+ ws.on("error", () => ws.terminate());
516
+
517
+ // Closed on purpose (1000, or 1001 as a page goes away), or lost: then the room holds the seat a while.
518
+ ws.on("close", (closeCode) => {
519
+ clearTimeout(helloTimer);
520
+ const room = rooms.get(code);
521
+ if (!room || !playerId || room.sockets.get(playerId) !== ws) return;
522
+ room.sockets.delete(playerId);
523
+ room.core.leave(playerId, closeCode === 1000 || closeCode === 1001 ? "left" : "lost");
524
+ dismissSpectatorsIfAlone(room);
525
+ pace(code, room);
526
+ closeIfEmpty(code, room);
527
+ });
528
+ });
529
+
530
+ // Every room saves as the server stops, so a clean restart loses nothing.
531
+ const close = () =>
532
+ new Promise((done) => {
533
+ clearInterval(sweeper);
534
+ clearInterval(catcher);
535
+ schedules.stop();
536
+ for (const [code, room] of rooms) {
537
+ if (room.timer) clearInterval(room.timer);
538
+ saveRoom(code, room);
539
+ }
540
+ // Players' games hear a restart (1012) and reconnect, to the same room once this server is back.
541
+ for (const ws of wss.clients) ws.close(1012, "Server restarting");
542
+ setTimeout(() => {
543
+ for (const ws of wss.clients) ws.terminate();
544
+ }, 2000).unref?.();
545
+ wss.close(() => httpServer.close(() => done()));
546
+ httpServer.closeAllConnections?.();
547
+ });
548
+ return { close, httpServer };
549
+ }
550
+
551
+ /** A saved state's key: its JSON, who was in the match and whether the room is kept, since any of them changing is worth a save. */
552
+ function stateKeyOf(stateJson, matchPlayers, isKept) {
553
+ return `${isKept ? "kept " : ""}${matchPlayers.map((player) => player.id).join(" ")}\n${stateJson}`;
554
+ }
555
+
556
+ /** A binary frame from ws (a Buffer, an ArrayBuffer or a fragmented frame's pieces) as a Uint8Array of its own. */
557
+ function binaryBytes(received) {
558
+ if (Array.isArray(received)) return new Uint8Array(Buffer.concat(received));
559
+ if (received instanceof ArrayBuffer) return new Uint8Array(received.slice(0));
560
+ return new Uint8Array(received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength));
561
+ }
562
+
563
+ const USAGE = "Usage: zaisma-game-server <server.ts | relay> --game <slug> [--port 8080] [--origin <url>,<url>] [--issuer-url https://rooms.zaisma.com] [--database-file zaisma-database.sqlite] [--room-state-file zaisma-room-states.sqlite]";
564
+ const FLAGS = ["game", "port", "origin", "issuer-url", "database-file", "room-state-file"];
565
+
566
+ /** The command line's flags by name, or null for one this runner doesn't take (or one missing its value). */
567
+ function flagsFrom(argumentList) {
568
+ const flags = {};
569
+ for (let i = 0; i < argumentList.length; i += 2) {
570
+ const name = /^--(.+)$/.exec(argumentList[i])?.[1];
571
+ if (!name || !FLAGS.includes(name) || argumentList[i + 1] === undefined || argumentList[i + 1].startsWith("--")) return null;
572
+ flags[name] = argumentList[i + 1];
573
+ }
574
+ return flags;
575
+ }
576
+
577
+ // Run as a command. An npm bin is a symlink to this file, so both sides are resolved before they're compared.
578
+ async function runCommand() {
579
+ const file = process.argv[2];
580
+ const flags = file ? flagsFrom(process.argv.slice(3)) : null;
581
+ if (!file || file.startsWith("--") || !flags?.game) {
582
+ console.log(USAGE);
583
+ process.exit(1);
584
+ }
585
+ const gameServer = file === "relay" ? relay : (await import(pathToFileURL(resolve(file)).href)).default;
586
+ const portNumber = Number(flags.port ?? "8080");
587
+ let running;
588
+ try {
589
+ running = startServer({
590
+ gameServer,
591
+ gameSlug: flags.game,
592
+ port: portNumber,
593
+ ...(flags.origin !== undefined ? { origins: flags.origin.split(",").filter(Boolean) } : {}),
594
+ ...(flags["issuer-url"] !== undefined ? { issuerUrl: flags["issuer-url"] } : {}),
595
+ ...(flags["database-file"] !== undefined ? { database: localDatabase(resolve(flags["database-file"])) } : {}),
596
+ roomStateFile: resolve(flags["room-state-file"] ?? "zaisma-room-states.sqlite"),
597
+ });
598
+ } catch (error) {
599
+ // A bad flag, or an option the runner refuses: said plainly, before anything listens.
600
+ console.error(error instanceof GameServerError ? error.message : error);
601
+ process.exit(1);
602
+ }
603
+ // Stopped (a restart, Ctrl-C): every room saves first, so its match comes back where it was.
604
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => running.close().then(() => process.exit(0)));
605
+ const scheduleNames = (gameServer.schedules ?? []).map((schedule) => `${schedule.name} (${schedule.cron})`);
606
+ const webhookNote = ` Webhooks come to POST /v1/webhooks${process.env.ZAISMA_WEBHOOK_SIGNING_SECRET ? "" : " once ZAISMA_WEBHOOK_SIGNING_SECRET is set"}${typeof gameServer.onWebhook === "function" ? "" : " (account_deleted only: it erases the player from the database)"}.`;
607
+ console.log(`Game server for "${flags.game}" on port ${portNumber}. Players connect to /v1/rooms/${flags.game}/<code>; requests come to POST /v1/requests/${flags.game}.${scheduleNames.length ? ` Schedules, in UTC: ${scheduleNames.join(", ")}.` : ""}${webhookNote}`);
608
+ }
609
+
610
+ // Started as the command, not imported: `node server` (no file extension) names a file realpathSync can't find, and that is an import, not the command.
611
+ let isCommand = false;
612
+ try {
613
+ isCommand = Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
614
+ } catch {}
615
+ if (isCommand) await runCommand();
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
+ "license": "MIT",
6
+ "homepage": "https://zaisma.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zaisma/zaisma.git",
10
+ "directory": "packages/game-server"
11
+ },
12
+ "bugs": "https://github.com/zaisma/zaisma/issues",
13
+ "keywords": [
14
+ "zaisma",
15
+ "games",
16
+ "browser-games",
17
+ "multiplayer",
18
+ "game-server",
19
+ "websocket"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./src/index.d.ts",
25
+ "default": "./src/index.js"
26
+ },
27
+ "./node": {
28
+ "types": "./bin/node.d.ts",
29
+ "default": "./bin/node.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "zaisma-game-server": "bin/node.js"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.18"
38
+ },
39
+ "dependencies": {
40
+ "ws": "8.21.3"
41
+ }
42
+ }