@zaisma/game-server 0.0.1 → 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/src/pass.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Player passes: standard JWTs signed with Ed25519 (EdDSA). Our rooms check them,
3
+ * and so can any developer's own server, using the keys published at
4
+ * /.well-known/jwks.json. Any JWT library that supports EdDSA works.
5
+ */
6
+ /** A pass's JWS header typ (RFC 8725 explicit typing): verifyPass refuses any other token (wrong_kind). */
7
+ export const PASS_TOKEN_TYPE = "pass+jwt";
8
+ /** The pass contract this copy reads: verifyPass refuses any other pass_version_number (wrong_version). */
9
+ export const PASS_VERSION_NUMBER = 1;
10
+ /** Zaisma's pass issuer: verifyPass expects it unless told another (a local preview of Zaisma). */
11
+ export const PASS_ISSUER_URL = "https://rooms.zaisma.com";
12
+ /** The pass helpers take passes verifyPass checked: anything else is a mistake in the server's code, not a player's. */
13
+ function mustBePasses(first, second, helper) {
14
+ for (const pass of [first, second]) {
15
+ if (!pass || typeof pass !== "object" || typeof pass.sub !== "string")
16
+ throw new TypeError(`${helper} takes two passes' claims, as verifyPass answered them.`);
17
+ }
18
+ }
19
+ /**
20
+ * Whether two players have a block between them, by the passes your server checked (verifyPass): either
21
+ * one blocked the other, which neither pass says. A block is a wall both ways: deliver nothing from one
22
+ * to the other (chat, voice, emotes) and don't match them together when you can help it, but never keep
23
+ * either out of a room. A new block reaches your server with the blocker's next pass, and the blocker's
24
+ * game also tells the room ({ type: "block", playerId }), which room.hasBlockBetween covers.
25
+ */
26
+ export function passesHaveBlockBetween(first, second) {
27
+ mustBePasses(first, second, "passesHaveBlockBetween");
28
+ if (first.sub === second.sub)
29
+ return false;
30
+ const lists = (pass, other) => typeof other.player_tag_id === "string" && Array.isArray(pass.block_player_tag_ids) && pass.block_player_tag_ids.includes(other.player_tag_id);
31
+ return lists(first, second) || lists(second, first);
32
+ }
33
+ /**
34
+ * Whether two players are Zaisma friends, by the passes your server checked (verifyPass): both accounts,
35
+ * each pass listing the other's player_tag_id, so removing a friend holds as soon as either side has a
36
+ * new pass. It works across rooms too: any two passes for the game. Use it for friends-only chat (canChat
37
+ * does), parties, or showing a player their friends; guests are never anyone's friend.
38
+ */
39
+ export function passesAreFriends(first, second) {
40
+ mustBePasses(first, second, "passesAreFriends");
41
+ if (first.kind !== "account" || second.kind !== "account" || first.sub === second.sub)
42
+ return false;
43
+ const lists = (pass, other) => typeof other.player_tag_id === "string" && Array.isArray(pass.friend_player_tag_ids) && pass.friend_player_tag_ids.includes(other.player_tag_id);
44
+ return lists(first, second) && lists(second, first);
45
+ }
46
+ /**
47
+ * Whether two players may chat, by the passes your server checked (verifyPass): both accounts, no block
48
+ * between them, neither letting nobody chat, and either friends or both letting anyone chat, in the same
49
+ * chat group. Guests and accounts set to nobody never chat; accounts set to friends chat only with
50
+ * friends (passesAreFriends). Use it on every message your own server delivers, text or voice; Zaisma's
51
+ * chat check (checkChat) applies the same rule, and Zaisma rooms will do the same for you.
52
+ */
53
+ export function canChat(first, second) {
54
+ mustBePasses(first, second, "canChat");
55
+ if (first.kind !== "account" || second.kind !== "account" || first.sub === second.sub)
56
+ return false;
57
+ if (passesHaveBlockBetween(first, second))
58
+ return false;
59
+ const isOpenTo = (whoCanChat) => whoCanChat === "friends" || whoCanChat === "anyone";
60
+ if (!isOpenTo(first.who_can_chat) || !isOpenTo(second.who_can_chat))
61
+ return false;
62
+ if (passesAreFriends(first, second))
63
+ return true;
64
+ return first.who_can_chat === "anyone" && second.who_can_chat === "anyone" && typeof first.chat_group_id === "string" && first.chat_group_id === second.chat_group_id;
65
+ }
66
+ // Plain class fields (no TypeScript-only syntax) so Node can run this file directly.
67
+ export class PassError extends Error {
68
+ code;
69
+ constructor(code) {
70
+ super(`Player pass rejected: ${code}`);
71
+ this.code = code;
72
+ }
73
+ }
74
+ export class ReceiptError extends Error {
75
+ code;
76
+ constructor(code) {
77
+ super(`Receipt rejected: ${code}`);
78
+ this.name = "ReceiptError";
79
+ this.code = code;
80
+ }
81
+ }
82
+ /** A receipt's JWS header typ (RFC 8725 explicit typing): verifyReceipt refuses any other token (wrong_kind). */
83
+ export const RECEIPT_TOKEN_TYPE = "receipt+jwt";
84
+ /** Who signs receipts on zaisma.com: a receipt's iss, apart from a pass's (PASS_ISSUER_URL). */
85
+ export const RECEIPT_ISSUER_URL = "https://rooms.zaisma.com/receipts";
86
+ /** The receipt contract this copy reads: verifyReceipt refuses any other receipt_version_number (wrong_version). */
87
+ export const RECEIPT_VERSION_NUMBER = 1;
88
+ const ED25519 = { name: "Ed25519" };
89
+ const encoder = new TextEncoder();
90
+ const decoder = new TextDecoder();
91
+ function toBase64Url(bytes) {
92
+ let binary = "";
93
+ for (const byte of bytes)
94
+ binary += String.fromCharCode(byte);
95
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
96
+ }
97
+ function fromBase64Url(text) {
98
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
99
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
100
+ }
101
+ async function keyId(publicKeyBase64Url) {
102
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(publicKeyBase64Url)));
103
+ return toBase64Url(digest).slice(0, 16);
104
+ }
105
+ /** Make a new signing key, as a JWK string for a signing secret (the rooms Worker's PASS_SIGNING_KEY and RECEIPT_SIGNING_KEY). */
106
+ export async function generateSigningKey() {
107
+ const pair = (await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]));
108
+ const jwk = (await crypto.subtle.exportKey("jwk", pair.privateKey));
109
+ return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d });
110
+ }
111
+ export async function loadSigningKey(secret) {
112
+ const jwk = JSON.parse(secret);
113
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x || !jwk.d)
114
+ throw new Error("A signing key must be an Ed25519 private JWK");
115
+ const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x, d: jwk.d }, ED25519, false, ["sign"]);
116
+ const publicKey = { kty: "OKP", crv: "Ed25519", x: jwk.x, kid: await keyId(jwk.x), alg: "EdDSA", use: "sig" };
117
+ return { key, publicKey };
118
+ }
119
+ /** A pass to sign: Zaisma always lists owned_item_names, and verifyPass reads a pass without them as owning nothing. */
120
+ export async function signPass(signing, claims) {
121
+ const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "EdDSA", typ: PASS_TOKEN_TYPE, kid: signing.publicKey.kid })));
122
+ const body = toBase64Url(encoder.encode(JSON.stringify(claims)));
123
+ const signature = new Uint8Array(await crypto.subtle.sign(ED25519, signing.key, encoder.encode(`${header}.${body}`)));
124
+ return `${header}.${body}.${toBase64Url(signature)}`;
125
+ }
126
+ export function jwks(keys) {
127
+ return { keys };
128
+ }
129
+ /**
130
+ * A token's claims once its signature checks out against one of the keys, for verifyPass and verifyReceipt, which
131
+ * say how to refuse. isKind checks the header's typ before any key is looked up, so a token of the other kind is
132
+ * wrong_kind wherever it's checked, whichever keys are at hand; it only ever refuses, so an unsigned header is enough.
133
+ */
134
+ async function signedToken(token, keys, isKind, refuse) {
135
+ const parts = typeof token === "string" ? token.split(".") : [];
136
+ if (parts.length !== 3)
137
+ throw refuse("malformed");
138
+ let header;
139
+ let claims;
140
+ try {
141
+ header = JSON.parse(decoder.decode(fromBase64Url(parts[0])));
142
+ claims = JSON.parse(decoder.decode(fromBase64Url(parts[1])));
143
+ }
144
+ catch {
145
+ throw refuse("malformed");
146
+ }
147
+ if (!header || header.alg !== "EdDSA" || !claims || typeof claims !== "object")
148
+ throw refuse("malformed");
149
+ if (!isKind(header.typ))
150
+ throw refuse("wrong_kind");
151
+ const jwk = keys.find((publicKey) => publicKey.kid === header.kid);
152
+ if (!jwk)
153
+ throw refuse("unknown_key");
154
+ const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
155
+ let signature;
156
+ try {
157
+ signature = fromBase64Url(parts[2]);
158
+ }
159
+ catch {
160
+ throw refuse("malformed");
161
+ }
162
+ if (!(await crypto.subtle.verify(ED25519, key, signature, encoder.encode(`${parts[0]}.${parts[1]}`))))
163
+ throw refuse("bad_signature");
164
+ return { claims };
165
+ }
166
+ const isStringList = (given) => Array.isArray(given) && given.every((entry) => typeof entry === "string");
167
+ /** Check a pass. Throws PassError if it's forged, expired, for another game, not a pass, or a version this copy doesn't know. */
168
+ export async function verifyPass(token, keys, expect) {
169
+ // A receipt, or any other token, never stands in for a pass, whichever keys it's checked against.
170
+ const { claims: signed } = await signedToken(token, keys, (typ) => typ === PASS_TOKEN_TYPE, (code) => new PassError(code));
171
+ const claims = signed;
172
+ if (claims.pass_version_number !== PASS_VERSION_NUMBER)
173
+ throw new PassError("wrong_version");
174
+ const nowSec = Math.floor((expect.nowMs ?? Date.now()) / 1000);
175
+ if (typeof claims.exp !== "number" || claims.exp <= nowSec)
176
+ throw new PassError("expired");
177
+ if (claims.iss !== (expect.issuerUrl ?? PASS_ISSUER_URL))
178
+ throw new PassError("wrong_issuer");
179
+ if (claims.aud !== expect.gameSlug)
180
+ throw new PassError("wrong_game");
181
+ if (typeof claims.sub !== "string" || typeof claims.kind !== "string")
182
+ throw new PassError("malformed");
183
+ if (claims.kind === "guest" && typeof claims.name !== "string")
184
+ throw new PassError("malformed");
185
+ if (claims.who_can_chat !== undefined && typeof claims.who_can_chat !== "string")
186
+ throw new PassError("malformed");
187
+ if (claims.chat_group_id !== undefined && typeof claims.chat_group_id !== "string")
188
+ throw new PassError("malformed");
189
+ if (claims.player_tag_id !== undefined && typeof claims.player_tag_id !== "string")
190
+ throw new PassError("malformed");
191
+ if (claims.friend_player_tag_ids !== undefined && !isStringList(claims.friend_player_tag_ids))
192
+ throw new PassError("malformed");
193
+ if (claims.block_player_tag_ids !== undefined && !isStringList(claims.block_player_tag_ids))
194
+ throw new PassError("malformed");
195
+ if (claims.owned_item_names === undefined)
196
+ claims.owned_item_names = [];
197
+ if (!isStringList(claims.owned_item_names))
198
+ throw new PassError("malformed");
199
+ return claims;
200
+ }
201
+ /** Check a receipt. Throws ReceiptError if it's forged, for another game, a pass (wrong_kind), past its exp, or a version this copy doesn't know. */
202
+ export async function verifyReceipt(token, keys, expect) {
203
+ const { claims: signed } = await signedToken(token, keys, (typ) => typ === RECEIPT_TOKEN_TYPE, (code) => new ReceiptError(code));
204
+ const claims = signed;
205
+ if (claims.receipt_version_number !== RECEIPT_VERSION_NUMBER)
206
+ throw new ReceiptError(Number.isInteger(claims.receipt_version_number) ? "wrong_version" : "malformed");
207
+ if (claims.exp !== undefined && (typeof claims.exp !== "number" || claims.exp <= Math.floor((expect.nowMs ?? Date.now()) / 1000)))
208
+ throw new ReceiptError("expired");
209
+ if (claims.iss !== (expect.issuerUrl ?? RECEIPT_ISSUER_URL))
210
+ throw new ReceiptError("wrong_issuer");
211
+ if (claims.aud !== expect.gameSlug)
212
+ throw new ReceiptError("wrong_game");
213
+ const isText = (given) => typeof given === "string" && given.length > 0;
214
+ const isNullOr = (given, type) => given === null || typeof given === type;
215
+ if (!isText(claims.sub) ||
216
+ !isText(claims.jti) ||
217
+ !isText(claims.purchase_id) ||
218
+ !isText(claims.purchased_at) ||
219
+ !isText(claims.purchase_status) ||
220
+ !isText(claims.item_name) ||
221
+ !isText(claims.item_kind) ||
222
+ !Number.isInteger(claims.item_count) ||
223
+ !isText(claims.purchase_source) ||
224
+ !isNullOr(claims.paid_minor_units, "number") ||
225
+ !isNullOr(claims.currency_code, "string") ||
226
+ !isNullOr(claims.game_account_id, "string") ||
227
+ typeof claims.iat !== "number") {
228
+ throw new ReceiptError("malformed");
229
+ }
230
+ return claims;
231
+ }
package/src/relay.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The built-in game server for games without server code: passes each message and bytes on to
3
+ * everyone else as they were sent, with fromPlayerId saying whose they are. With no server code to decide, the
4
+ * room's first player may hide, show, open and close it, and each player's game sets its own idle check
5
+ * (joinRoom({ idleTimeoutSec, idleCheckEnabled })).
6
+ */
7
+ export declare const relay: import("./room.ts").GameServer<{}>;
package/src/relay.js ADDED
@@ -0,0 +1,20 @@
1
+ import { defineGameServer } from "./room.js";
2
+ /**
3
+ * The built-in game server for games without server code: passes each message and bytes on to
4
+ * everyone else as they were sent, with fromPlayerId saying whose they are. With no server code to decide, the
5
+ * room's first player may hide, show, open and close it, and each player's game sets its own idle check
6
+ * (joinRoom({ idleTimeoutSec, idleCheckEnabled })).
7
+ */
8
+ export const relay = defineGameServer({
9
+ // Not the rooms' 100: the relay sends every message to everyone, so a room's sends grow with the square of its players.
10
+ maximumPlayerCount: 16,
11
+ hostControlsEnabled: true,
12
+ idleCheck: "client",
13
+ setup: () => ({}),
14
+ onMessage(state, player, room, message) {
15
+ room.broadcast(message, { exceptPlayerId: player.id, fromPlayerId: player.id });
16
+ },
17
+ onBytes(state, player, room, bytes) {
18
+ room.broadcastBytes(bytes, { exceptPlayerId: player.id, fromPlayerId: player.id });
19
+ },
20
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Requests: plain calls from a game to its game server's onRequest, outside any room, for a
3
+ * leaderboard, a shop or a daily reward in any game, single-player ones too. Zaisma rooms answer them
4
+ * at rooms.zaisma.com (apps/rooms/src/game-server.ts) and the Node runner on the developer's own
5
+ * server (@zaisma/game-server/node), both at POST /v1/requests/<game>, both checked here so a request that works
6
+ * in one works the same in the other.
7
+ *
8
+ * The game sends { pass, request } as text/plain JSON (so the browser needs no preflight): its player
9
+ * pass, so onRequest knows who's asking, and its request, plain JSON. It gets back { answer }, what
10
+ * onRequest answered, or { error: { code, message } }.
11
+ */
12
+ import type { GameDatabase } from "./database.ts";
13
+ import type { ReceiptClaims } from "./pass.ts";
14
+ import type { Player } from "./room.ts";
15
+ /**
16
+ * What onRequest, onSchedule and onWebhook reach besides what they're given: the game's own database, the
17
+ * same one its rooms use, its secrets, and receipt checks.
18
+ */
19
+ export interface Server {
20
+ database: GameDatabase;
21
+ /**
22
+ * The game's secrets, by name, like server.secrets.DISCORD_WEBHOOK_URL: set per game on Zaisma
23
+ * (set_secret) and never shown again, or the environment variables on your own server.
24
+ */
25
+ secrets: Readonly<Record<string, string>>;
26
+ /** Check a purchase's receipt, as room.verifyReceipt does: the runtime fills in Zaisma's keys and this game. */
27
+ verifyReceipt(receipt: string): Promise<ReceiptClaims>;
28
+ }
29
+ export declare const REQUEST_LIMITS: {
30
+ /** A request, as JSON. */
31
+ readonly maximumRequestSizeBytes: number;
32
+ /**
33
+ * The player pass that comes with it, or with a room's hello: the fullest pass Zaisma makes (1,000 owned
34
+ * items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
35
+ */
36
+ readonly maximumPassSizeBytes: number;
37
+ /** What onRequest answers, as JSON. */
38
+ readonly maximumAnswerSizeBytes: number;
39
+ /** How long onRequest may take, awaiting the database included, before the request fails. */
40
+ readonly answerTimeoutSec: 10;
41
+ };
42
+ export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "account_needed" | "game_not_open_to_player" | "game_unavailable" | "no_request_handler" | "game_server_error" | "hosting_paused" | "slow_down" | "bad_origin" | "busy";
43
+ export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
44
+ /** The HTTP status each request error answers with. */
45
+ export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
46
+ /** The body of an error answer. message replaces the usual text. */
47
+ export declare function requestError(code: RequestErrorCode, message?: string): {
48
+ error: {
49
+ code: RequestErrorCode;
50
+ message: string;
51
+ };
52
+ };
53
+ /**
54
+ * Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
55
+ * (nothing is null). The pass's own size doesn't count.
56
+ */
57
+ export declare function readRequestBody(text: string): {
58
+ pass: string;
59
+ request: unknown;
60
+ } | {
61
+ code: "bad_request" | "too_big";
62
+ };
63
+ /**
64
+ * What onRequest answered, as the game gets it: plain JSON (nothing is null), within
65
+ * REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
66
+ */
67
+ export declare function readRequestAnswer(answer: unknown): {
68
+ answer: unknown;
69
+ } | {
70
+ mistake: string;
71
+ };
72
+ /** A game server that answers requests. */
73
+ export interface RequestHandler {
74
+ onRequest?(player: Player, request: unknown, server: Server): unknown;
75
+ }
76
+ /**
77
+ * Run a game server's onRequest for one request, with a time limit, and say what the game gets. A
78
+ * game server without onRequest answers no_request_handler. When it throws, answers something that
79
+ * isn't plain JSON or takes too long, the game gets game_server_error, and failure says why for the
80
+ * game server's errors.
81
+ */
82
+ export declare function answerRequest(handler: RequestHandler, player: Player, request: unknown, server: Server): Promise<{
83
+ answer: unknown;
84
+ } | {
85
+ code: "no_request_handler";
86
+ } | {
87
+ code: "game_server_error";
88
+ failure: string;
89
+ }>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Requests: plain calls from a game to its game server's onRequest, outside any room, for a
3
+ * leaderboard, a shop or a daily reward in any game, single-player ones too. Zaisma rooms answer them
4
+ * at rooms.zaisma.com (apps/rooms/src/game-server.ts) and the Node runner on the developer's own
5
+ * server (@zaisma/game-server/node), both at POST /v1/requests/<game>, both checked here so a request that works
6
+ * in one works the same in the other.
7
+ *
8
+ * The game sends { pass, request } as text/plain JSON (so the browser needs no preflight): its player
9
+ * pass, so onRequest knows who's asking, and its request, plain JSON. It gets back { answer }, what
10
+ * onRequest answered, or { error: { code, message } }.
11
+ */
12
+ export const REQUEST_LIMITS = {
13
+ /** A request, as JSON. */
14
+ maximumRequestSizeBytes: 16 * 1024,
15
+ /**
16
+ * The player pass that comes with it, or with a room's hello: the fullest pass Zaisma makes (1,000 owned
17
+ * items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
18
+ */
19
+ maximumPassSizeBytes: 96 * 1024,
20
+ /** What onRequest answers, as JSON. */
21
+ maximumAnswerSizeBytes: 1024 * 1024,
22
+ /** How long onRequest may take, awaiting the database included, before the request fails. */
23
+ answerTimeoutSec: 10,
24
+ };
25
+ export const REQUEST_ERROR_TEXT = {
26
+ bad_request: "Requests are JSON with the player's pass and the game's request: { pass, request }.",
27
+ too_big: `Requests are up to ${REQUEST_LIMITS.maximumRequestSizeBytes / 1024} KB.`,
28
+ bad_pass: "Your player pass isn't valid for this game.",
29
+ account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
30
+ game_not_open_to_player: "This game isn't open to this account.",
31
+ game_unavailable: "This game isn't available right now.",
32
+ no_request_handler: "This game's server doesn't take requests.",
33
+ game_server_error: "The game's server couldn't answer this request.",
34
+ hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
35
+ slow_down: "Too many requests. Try again in a minute.",
36
+ bad_origin: "Not allowed from here.",
37
+ busy: "Zaisma is busy. Try again soon.",
38
+ };
39
+ /** The HTTP status each request error answers with. */
40
+ export const REQUEST_ERROR_HTTP_STATUS = {
41
+ bad_request: 400,
42
+ too_big: 413,
43
+ bad_pass: 401,
44
+ account_needed: 403,
45
+ game_not_open_to_player: 403,
46
+ game_unavailable: 404,
47
+ no_request_handler: 404,
48
+ game_server_error: 500,
49
+ hosting_paused: 403,
50
+ slow_down: 429,
51
+ bad_origin: 403,
52
+ busy: 503,
53
+ };
54
+ /** The body of an error answer. message replaces the usual text. */
55
+ export function requestError(code, message) {
56
+ return { error: { code, message: message || REQUEST_ERROR_TEXT[code] } };
57
+ }
58
+ /**
59
+ * Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
60
+ * (nothing is null). The pass's own size doesn't count.
61
+ */
62
+ export function readRequestBody(text) {
63
+ let body;
64
+ try {
65
+ body = JSON.parse(text);
66
+ }
67
+ catch {
68
+ return { code: "bad_request" };
69
+ }
70
+ if (!body || typeof body !== "object" || Array.isArray(body))
71
+ return { code: "bad_request" };
72
+ const { pass, request } = body;
73
+ if (typeof pass !== "string" || pass.length > REQUEST_LIMITS.maximumPassSizeBytes)
74
+ return { code: "bad_request" };
75
+ const requestText = request === undefined ? "null" : JSON.stringify(request);
76
+ if (new TextEncoder().encode(requestText).length > REQUEST_LIMITS.maximumRequestSizeBytes)
77
+ return { code: "too_big" };
78
+ return { pass, request: request === undefined ? null : request };
79
+ }
80
+ /**
81
+ * What onRequest answered, as the game gets it: plain JSON (nothing is null), within
82
+ * REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
83
+ */
84
+ export function readRequestAnswer(answer) {
85
+ if (answer === undefined || answer === null)
86
+ return { answer: null };
87
+ let text;
88
+ try {
89
+ text = JSON.stringify(answer);
90
+ }
91
+ catch {
92
+ text = undefined;
93
+ }
94
+ if (text === undefined)
95
+ return { mistake: "onRequest answered something that isn't plain JSON, like a function or an object that refers to itself. Answer numbers, text, true or false, null, lists and plain objects." };
96
+ if (new TextEncoder().encode(text).length > REQUEST_LIMITS.maximumAnswerSizeBytes) {
97
+ return { mistake: `onRequest answered over ${REQUEST_LIMITS.maximumAnswerSizeBytes / 1024 / 1024} MB. Answer less, like one page of a leaderboard at a time.` };
98
+ }
99
+ return { answer: JSON.parse(text) };
100
+ }
101
+ /**
102
+ * Run a game server's onRequest for one request, with a time limit, and say what the game gets. A
103
+ * game server without onRequest answers no_request_handler. When it throws, answers something that
104
+ * isn't plain JSON or takes too long, the game gets game_server_error, and failure says why for the
105
+ * game server's errors.
106
+ */
107
+ export async function answerRequest(handler, player, request, server) {
108
+ if (typeof handler.onRequest !== "function")
109
+ return { code: "no_request_handler" };
110
+ let timer;
111
+ try {
112
+ const answer = await Promise.race([
113
+ Promise.resolve().then(() => handler.onRequest(player, request, server)),
114
+ new Promise((_, reject) => {
115
+ timer = setTimeout(() => reject(new Error(`onRequest didn't answer within ${REQUEST_LIMITS.answerTimeoutSec} seconds`)), REQUEST_LIMITS.answerTimeoutSec * 1000);
116
+ }),
117
+ ]);
118
+ const read = readRequestAnswer(answer);
119
+ return "mistake" in read ? { code: "game_server_error", failure: read.mistake } : read;
120
+ }
121
+ catch (error) {
122
+ return { code: "game_server_error", failure: String((error instanceof Error && error.stack) || error) };
123
+ }
124
+ finally {
125
+ clearTimeout(timer);
126
+ }
127
+ }
@@ -0,0 +1,92 @@
1
+ /** The room limits the checks read; ROOM_LIMITS has all of them. */
2
+ export interface RoomCallLimits {
3
+ maximumKickMessageCharacterCount: number;
4
+ maximumLatestKeyCharacterCount: number;
5
+ maximumSetupDataSizeBytes: number;
6
+ maximumJoinDataSizeBytes: number;
7
+ maximumNameCharacterCount: number;
8
+ maximumLabelNameCharacterCount: number;
9
+ maximumLabelValueCharacterCount: number;
10
+ maximumLabelCount: number;
11
+ }
12
+ /** The error a mistake in a room call throws: GameServerError, or the sandbox's copy of it. */
13
+ export type RoomCallErrorClass = new (code: "bad_room_call" | "room_call_unavailable", field: string, message: string, fix: string) => Error;
14
+ /** A send's delivery as the room acts on it: latest-only, with its latestKey, or reliable. */
15
+ export type CheckedDelivery = {
16
+ isLatestOnly: false;
17
+ } | {
18
+ isLatestOnly: true;
19
+ latestKey: string;
20
+ };
21
+ export interface CheckedSend {
22
+ delivery: CheckedDelivery;
23
+ fromPlayerId: string | null;
24
+ }
25
+ export interface CheckedBroadcast extends CheckedSend {
26
+ audience: "everyone" | "players" | "spectators";
27
+ exceptPlayerId: string | undefined;
28
+ }
29
+ export interface CheckedReserve {
30
+ playerIds: string[];
31
+ setupData: unknown;
32
+ isListed: boolean;
33
+ friendJoinsEnabled: boolean;
34
+ isKept: boolean;
35
+ name: string | null;
36
+ labels: Record<string, string>;
37
+ }
38
+ export interface CheckedMove {
39
+ playerIds: string[];
40
+ joinData: unknown;
41
+ }
42
+ export interface RoomCallChecks {
43
+ checkBroadcast(message: unknown, broadcastOptions: unknown): CheckedBroadcast;
44
+ checkSend(playerId: unknown, message: unknown, sendOptions: unknown): CheckedSend & {
45
+ playerId: string;
46
+ };
47
+ checkBroadcastBytes(bytes: unknown, broadcastOptions: unknown): CheckedBroadcast & {
48
+ bytes: Uint8Array;
49
+ };
50
+ checkSendBytes(playerId: unknown, bytes: unknown, sendOptions: unknown): CheckedSend & {
51
+ playerId: string;
52
+ bytes: Uint8Array;
53
+ };
54
+ checkKick(playerId: unknown, kickOptions: unknown): {
55
+ playerId: string;
56
+ message: string;
57
+ isKeptOut: boolean;
58
+ };
59
+ checkReserve(reserveOptions: unknown, playerIdsInRoom: readonly string[]): CheckedReserve;
60
+ checkMoveTo(toRoomCode: unknown, moveOptions: unknown, playerIdsInRoom: readonly string[]): CheckedMove & {
61
+ toRoomCode: string;
62
+ };
63
+ checkSetInfo(nameAndLabels: unknown): {
64
+ name?: string | null;
65
+ labels?: Record<string, string>;
66
+ };
67
+ checkEnd(message: unknown): string | undefined;
68
+ /** Text with hidden characters dropped, spaces collapsed, cut to the length. */
69
+ tidy(given: unknown, maximumCharacterCount: number): string;
70
+ /** A name and labels within the limits, with hidden characters dropped. Fields left out stay out. */
71
+ tidyRoomInfo(nameAndLabels: {
72
+ name?: unknown;
73
+ labels?: unknown;
74
+ }): {
75
+ name?: string | null;
76
+ labels?: Record<string, string>;
77
+ };
78
+ /** Plain JSON within maximumSizeBytes, copied, or isValid false. Nothing (undefined or null) is null. */
79
+ checkRoomData(given: unknown, maximumSizeBytes: number): {
80
+ isValid: true;
81
+ checkedData: unknown;
82
+ } | {
83
+ isValid: false;
84
+ };
85
+ }
86
+ /** The checks for a runtime: pass its GameServerError class and the room limits. */
87
+ export declare function roomCallChecks(ErrorClass: RoomCallErrorClass, limits: RoomCallLimits): RoomCallChecks;
88
+ /**
89
+ * The same parts as an ES module's source, for the game server's sandbox: export default is { checksFor }.
90
+ * A bundler that names functions (esbuild's keepNames) calls __name inside them, so the module gives it.
91
+ */
92
+ export declare function roomCallChecksModuleSource(): string;