@zaisma/game-server 0.0.1 → 1.1.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/dump.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The game database in and out as SQLite's own SQL dump (docs/DECISIONS.md, "Import, export and undo"):
3
+ * the CREATE TABLEs and INSERTs that `sqlite3 game.sqlite .dump` writes, so a dump from Zaisma loads into
4
+ * any SQLite (`sqlite3 game.sqlite < dump.sql`, the Node runner's file) and a dump from there loads into
5
+ * Zaisma. Zaisma's database is a Durable Object (apps/rooms/src/database.ts); the upload command keeps a
6
+ * copy of the same reading for .sqlite files (packages/cli/zaisma.mjs), held to this by test/node/dump.test.ts.
7
+ */
8
+ import { type DatabaseRow, type DatabaseValue } from "./database.ts";
9
+ /** Runs one SELECT for the dump: its rows, and the rows SQLite read for it (they count towards the month's reads). */
10
+ export type DumpRead = (sql: string, values: DatabaseValue[]) => {
11
+ rows: DatabaseRow[];
12
+ readRowCount: number;
13
+ };
14
+ /** One part of a dump, and where the next one starts. */
15
+ export interface DumpPart {
16
+ dumpSql: string;
17
+ /** null on the last part. */
18
+ nextCursor: string | null;
19
+ readRowCount: number;
20
+ }
21
+ export declare const DUMP_LIMITS: {
22
+ /** The most SQL one part of an export holds, unless a single row is bigger on its own. */
23
+ readonly exportPartSizeBytes: number;
24
+ /** The most one part of an import may hold: a little over an export's part, so every export part fits. */
25
+ readonly maximumImportPartSizeBytes: number;
26
+ };
27
+ /** A cursor that isn't an earlier part's next_cursor, or whose table is gone. */
28
+ export declare class DumpCursorError extends Error {
29
+ }
30
+ /** A name in double quotes, as SQLite takes any table or column name. */
31
+ export declare function quoteName(name: string): string;
32
+ /**
33
+ * One part of the database's dump, up to partSizeBytes of SQL (more only when one row is bigger on its own),
34
+ * starting where cursor says, or at the start for null. The parts, put together in order, are one whole dump. It reads
35
+ * table by table, so a game writing while it runs can land between parts: it isn't a snapshot.
36
+ */
37
+ export declare function dumpPart(read: DumpRead, cursor: string | null, partSizeBytes?: number): DumpPart;
38
+ /** How many bytes text takes as UTF-8, without making the bytes. */
39
+ export declare function utf8SizeBytes(text: string): number;
40
+ /** A statement of a dump, ready to run: its SQL, the values for its ?s, and which statement of the part it came from (from 1). */
41
+ export interface ImportStatement {
42
+ sql: string;
43
+ values: DatabaseValue[];
44
+ statementNumber: number;
45
+ }
46
+ /** What a part of a dump runs: its statements, and how many it left out (the transaction and settings sqlite3 writes around them). */
47
+ export interface ImportPlan {
48
+ statements: ImportStatement[];
49
+ statementCount: number;
50
+ skippedStatementCount: number;
51
+ }
52
+ /**
53
+ * Reads a part of a dump into the statements to run. Import only adds: it takes CREATE and INSERT
54
+ * statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
55
+ * sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
56
+ * statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
57
+ * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
58
+ */
59
+ export declare function importStatements(dumpSql: string): ImportPlan;
60
+ /** The start of a statement, for an error. */
61
+ export declare function preview(text: string): string;
package/src/dump.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The game database in and out as SQLite's own SQL dump (docs/DECISIONS.md, "Import, export and undo"):
3
+ * the CREATE TABLEs and INSERTs that `sqlite3 game.sqlite .dump` writes, so a dump from Zaisma loads into
4
+ * any SQLite (`sqlite3 game.sqlite < dump.sql`, the Node runner's file) and a dump from there loads into
5
+ * Zaisma. Zaisma's database is a Durable Object (apps/rooms/src/database.ts); the upload command keeps a
6
+ * copy of the same reading for .sqlite files (packages/cli/zaisma.mjs), held to this by test/node/dump.test.ts.
7
+ */
8
+ import { DATABASE_LIMITS, DatabaseError, scanStatements } from "./database.js";
9
+ export const DUMP_LIMITS = {
10
+ /** The most SQL one part of an export holds, unless a single row is bigger on its own. */
11
+ exportPartSizeBytes: 4 * 1024 * 1024,
12
+ /** The most one part of an import may hold: a little over an export's part, so every export part fits. */
13
+ maximumImportPartSizeBytes: 5 * 1024 * 1024,
14
+ };
15
+ /** A cursor that isn't an earlier part's next_cursor, or whose table is gone. */
16
+ export class DumpCursorError extends Error {
17
+ }
18
+ /** Rows read per SELECT while dumping a table. */
19
+ const PAGE_ROW_COUNT = 500;
20
+ /** A dump starts and ends the way sqlite3's does, so it loads in one transaction anywhere. */
21
+ const DUMP_START = "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
22
+ const DUMP_END = "COMMIT;\n";
23
+ // What's in the database, in the order it was made: tables, views, indexes and triggers, with what kind
24
+ // of table each is (a normal one, a virtual one, or a virtual table's own "shadow" table) and whether it has
25
+ // rowids. Cloudflare's own tables (_cf_…) are never the developer's.
26
+ const SCHEMA_SQL = "SELECT entry.type AS type, entry.name AS name, entry.tbl_name AS table_name, entry.sql AS sql, listed.type AS table_kind, listed.wr AS is_without_rowid " +
27
+ "FROM sqlite_schema AS entry LEFT JOIN pragma_table_list AS listed ON listed.schema = 'main' AND listed.name = entry.name " +
28
+ "WHERE entry.sql IS NOT NULL AND substr(entry.tbl_name, 1, 4) <> '_cf_' ORDER BY entry.rowid";
29
+ /** A name in double quotes, as SQLite takes any table or column name. */
30
+ export function quoteName(name) {
31
+ return `"${name.replaceAll('"', '""')}"`;
32
+ }
33
+ /**
34
+ * One part of the database's dump, up to partSizeBytes of SQL (more only when one row is bigger on its own),
35
+ * starting where cursor says, or at the start for null. The parts, put together in order, are one whole dump. It reads
36
+ * table by table, so a game writing while it runs can land between parts: it isn't a snapshot.
37
+ */
38
+ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSizeBytes) {
39
+ let readRowCount = 0;
40
+ const select = (sql, values = []) => {
41
+ const answer = read(sql, values);
42
+ readRowCount += answer.readRowCount;
43
+ return answer.rows;
44
+ };
45
+ const entries = select(SCHEMA_SQL);
46
+ const tables = entries.filter((entry) => entry.type === "table" && (entry.table_kind === "table" || entry.table_kind === "virtual") && !entry.name.startsWith("sqlite_"));
47
+ let dumpSql = "";
48
+ let dumpSizeBytes = 0;
49
+ let hasRows = false;
50
+ const add = (sql) => {
51
+ dumpSql += sql;
52
+ dumpSizeBytes += utf8SizeBytes(sql);
53
+ };
54
+ let tableIndex = 0;
55
+ let position = null;
56
+ if (cursor === null)
57
+ add(DUMP_START);
58
+ else {
59
+ position = readCursor(cursor);
60
+ tableIndex = tables.findIndex((table) => table.name === position.table_name);
61
+ if (tableIndex === -1)
62
+ throw new DumpCursorError(`The table ${position.table_name} isn't there any more.`);
63
+ }
64
+ for (; tableIndex < tables.length; tableIndex++) {
65
+ const table = tables[tableIndex];
66
+ if (!position)
67
+ add(`${table.sql};\n`);
68
+ position ??= { table_name: table.name, last_rowid_number: table.is_without_rowid ? null : 0, dumped_row_count: 0 };
69
+ const columns = select("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid", [table.name]);
70
+ // Generated columns, and a virtual table's hidden ones, can't be inserted: the INSERTs name the rest.
71
+ const insertedColumns = columns.filter((column) => column.is_insertable === 1);
72
+ const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
73
+ // quote() writes each value as SQL, the way sqlite3's .dump does: 'text' with '' for a quote, X'…' for bytes.
74
+ const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
75
+ const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
76
+ while (true) {
77
+ const rows = position.last_rowid_number === null
78
+ ? select(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${primaryKey.map((column) => quoteName(column.name)).join(", ")} LIMIT ${PAGE_ROW_COUNT} OFFSET ?`, [position.dumped_row_count])
79
+ : select(`SELECT rowid AS paging_rowid, ${valuesSql} FROM ${quoteName(table.name)} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number]);
80
+ for (const row of rows) {
81
+ const insert = `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
82
+ // The part ends before a row that would take it past partSizeBytes; the next starts with that row.
83
+ if (hasRows && dumpSizeBytes + utf8SizeBytes(insert) > partSizeBytes)
84
+ return { dumpSql, nextCursor: writeCursor(position), readRowCount };
85
+ add(insert);
86
+ hasRows = true;
87
+ if (position.last_rowid_number === null)
88
+ position.dumped_row_count++;
89
+ else
90
+ position.last_rowid_number = row.paging_rowid;
91
+ }
92
+ if (rows.length < PAGE_ROW_COUNT)
93
+ break;
94
+ }
95
+ position = null;
96
+ }
97
+ // The rest is small: AUTOINCREMENT's counters, then views, indexes and triggers, made after the rows
98
+ // so triggers don't fire on them, then the end.
99
+ if (entries.some((entry) => entry.name === "sqlite_sequence")) {
100
+ add("DELETE FROM sqlite_sequence;\n");
101
+ for (const counter of select("SELECT quote(name) AS name_sql, quote(seq) AS counter_sql FROM sqlite_sequence"))
102
+ add(`INSERT INTO sqlite_sequence VALUES(${counter.name_sql},${counter.counter_sql});\n`);
103
+ }
104
+ for (const entry of entries) {
105
+ if ((entry.type === "view" || entry.type === "index" || entry.type === "trigger") && !entry.table_name.startsWith("sqlite_"))
106
+ add(`${entry.sql};\n`);
107
+ }
108
+ add(DUMP_END);
109
+ return { dumpSql, nextCursor: null, readRowCount };
110
+ }
111
+ /** How many bytes text takes as UTF-8, without making the bytes. */
112
+ export function utf8SizeBytes(text) {
113
+ let sizeBytes = 0;
114
+ for (let i = 0; i < text.length; i++) {
115
+ const codeUnitNumber = text.charCodeAt(i);
116
+ // A surrogate pair is one character of 4 bytes, 2 for each half.
117
+ sizeBytes += codeUnitNumber < 0x80 ? 1 : codeUnitNumber < 0x800 ? 2 : codeUnitNumber >= 0xd800 && codeUnitNumber < 0xe000 ? 2 : 3;
118
+ }
119
+ return sizeBytes;
120
+ }
121
+ function writeCursor(position) {
122
+ const bytes = new TextEncoder().encode(JSON.stringify(position));
123
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
124
+ }
125
+ function readCursor(cursor) {
126
+ let position;
127
+ try {
128
+ const binary = atob(cursor.replaceAll("-", "+").replaceAll("_", "/"));
129
+ position = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
130
+ }
131
+ catch {
132
+ throw new DumpCursorError("The cursor isn't one an export answered.");
133
+ }
134
+ const { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount } = (position ?? {});
135
+ if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !Number.isInteger(dumpedRowCount))
136
+ throw new DumpCursorError("The cursor isn't one an export answered.");
137
+ return { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount };
138
+ }
139
+ const IMPORT_FIX = "Import takes a dump's CREATE and INSERT statements, as sqlite3's .dump or export_database writes them; it only adds. Run other changes with query_database or batch_database.";
140
+ /**
141
+ * Reads a part of a dump into the statements to run. Import only adds: it takes CREATE and INSERT
142
+ * statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
143
+ * sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
144
+ * statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
145
+ * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
146
+ */
147
+ export function importStatements(dumpSql) {
148
+ const statements = [];
149
+ const scanned = scanStatements(dumpSql);
150
+ let skippedStatementCount = 0;
151
+ scanned.forEach((scannedStatement, i) => {
152
+ const statementNumber = i + 1;
153
+ const text = dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
154
+ const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
155
+ const words = scannedStatement.leadingWords.join(" ");
156
+ const isSkipped = ["BEGIN", "COMMIT", "END", "ROLLBACK", "ANALYZE"].includes(firstWord) ||
157
+ (firstWord === "PRAGMA" && (secondWord === "FOREIGN_KEYS" || secondWord === "WRITABLE_SCHEMA")) ||
158
+ /^CREATE TABLE (IF NOT EXISTS )?SQLITE_SEQUENCE$/.test(words) ||
159
+ words === "DELETE FROM SQLITE_SEQUENCE" ||
160
+ (firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_STAT1" || thirdWord === "SQLITE_STAT4"));
161
+ if (isSkipped) {
162
+ skippedStatementCount++;
163
+ return;
164
+ }
165
+ const refuse = (message) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, IMPORT_FIX);
166
+ if (firstWord === "CREATE") {
167
+ statements.push({ sql: text, values: [], statementNumber });
168
+ return;
169
+ }
170
+ if (firstWord !== "INSERT")
171
+ throw refuse(`${firstWord || "it"} isn't a CREATE or INSERT.`);
172
+ if (secondWord === "OR" && thirdWord === "REPLACE")
173
+ throw refuse("INSERT OR REPLACE changes rows that are there.");
174
+ const bound = bindLiterals(text, refuse);
175
+ if (thirdWord === "SQLITE_SEQUENCE") {
176
+ // sqlite_sequence has a row per table and no key: set the counter, never lower it.
177
+ const counterMatch = /VALUES\s*\(\s*\?\s*,\s*(\d+)\s*\)$/i.exec(bound.sql);
178
+ if (!counterMatch || bound.values.length !== 1)
179
+ throw refuse("sqlite_sequence takes a table's name and its counter, like INSERT INTO sqlite_sequence VALUES('scores',12).");
180
+ const tableName = bound.values[0];
181
+ const counter = counterMatch[1];
182
+ // The counter goes in the SQL (it's digits only): bound, a number would be stored as a REAL.
183
+ statements.push({ sql: `INSERT INTO sqlite_sequence (name, seq) SELECT ?, ${counter} WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`, values: [tableName, tableName], statementNumber });
184
+ statements.push({ sql: `UPDATE sqlite_sequence SET seq = max(seq, ${counter}) WHERE name = ?`, values: [tableName], statementNumber });
185
+ return;
186
+ }
187
+ if (bound.values.length > DATABASE_LIMITS.maximumValueCount)
188
+ throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
189
+ statements.push({ sql: bound.sql, values: bound.values, statementNumber });
190
+ });
191
+ return { statements, statementCount: scanned.length, skippedStatementCount };
192
+ }
193
+ /** The start of a statement, for an error. */
194
+ export function preview(text) {
195
+ const oneLine = text.replace(/\s+/g, " ");
196
+ return oneLine.length > 80 ? `${oneLine.slice(0, 79)}…` : oneLine;
197
+ }
198
+ /**
199
+ * An INSERT with its text and blob literals as ?s, and their values: 'it''s' becomes "it's", X'00ff' the
200
+ * bytes and unistr('a\u000ab') the text it stands for. Numbers, NULL and names stay as they are.
201
+ */
202
+ function bindLiterals(text, refuse) {
203
+ const values = [];
204
+ let sql = "";
205
+ let i = 0;
206
+ /** The text of the 'quoted' literal starting at startIndex, and where it ends. */
207
+ const readQuoted = (startIndex) => {
208
+ let endIndex = startIndex + 1;
209
+ let literal = "";
210
+ while (endIndex < text.length) {
211
+ if (text[endIndex] === "'") {
212
+ if (text[endIndex + 1] !== "'")
213
+ return { literal, endIndex: endIndex + 1 };
214
+ literal += "'";
215
+ endIndex += 2;
216
+ }
217
+ else
218
+ literal += text[endIndex++];
219
+ }
220
+ throw refuse("a quote isn't closed.");
221
+ };
222
+ const isWordCharacter = (character) => character !== undefined && /[A-Za-z0-9_$]/.test(character);
223
+ while (i < text.length) {
224
+ const character = text[i];
225
+ const afterWord = isWordCharacter(text[i - 1]);
226
+ if (character === "-" && text[i + 1] === "-") {
227
+ const endIndex = text.indexOf("\n", i);
228
+ i = endIndex === -1 ? text.length : endIndex + 1;
229
+ sql += " ";
230
+ }
231
+ else if (character === "/" && text[i + 1] === "*") {
232
+ const endIndex = text.indexOf("*/", i + 2);
233
+ i = endIndex === -1 ? text.length : endIndex + 2;
234
+ sql += " ";
235
+ }
236
+ else if (character === '"' || character === "`" || character === "[") {
237
+ // A name: copied as it is.
238
+ const close = character === "[" ? "]" : character;
239
+ let endIndex = i + 1;
240
+ while (endIndex < text.length && !(text[endIndex] === close && text[endIndex + 1] !== close))
241
+ endIndex += text[endIndex] === close ? 2 : 1;
242
+ sql += text.slice(i, endIndex + 1);
243
+ i = endIndex + 1;
244
+ }
245
+ else if (character === "'") {
246
+ const { literal, endIndex } = readQuoted(i);
247
+ values.push(literal);
248
+ sql += "?";
249
+ i = endIndex;
250
+ }
251
+ else if ((character === "x" || character === "X") && text[i + 1] === "'" && !afterWord) {
252
+ const { literal, endIndex } = readQuoted(i + 1);
253
+ if (literal.length % 2 || /[^0-9A-Fa-f]/.test(literal))
254
+ throw refuse(`X'${literal.slice(0, 20)}' isn't bytes in hex.`);
255
+ values.push(Uint8Array.from({ length: literal.length / 2 }, (_, byteIndex) => parseInt(literal.slice(byteIndex * 2, byteIndex * 2 + 2), 16)));
256
+ sql += "?";
257
+ i = endIndex;
258
+ }
259
+ else if (!afterWord && /^unistr\s*\(\s*'/i.test(text.slice(i, i + 40))) {
260
+ const quoteIndex = text.indexOf("'", i);
261
+ const { literal, endIndex } = readQuoted(quoteIndex);
262
+ const closeMatch = /^\s*\)/.exec(text.slice(endIndex));
263
+ if (!closeMatch)
264
+ throw refuse("unistr( takes one 'text'.");
265
+ values.push(literal.replace(/\\(\\|u[0-9A-Fa-f]{4}|\+[0-9A-Fa-f]{6}|U[0-9A-Fa-f]{8}|[0-9A-Fa-f]{4})/g, (_, escape) => (escape === "\\" ? "\\" : String.fromCodePoint(parseInt(escape.replace(/^[uU+]/, ""), 16)))));
266
+ sql += "?";
267
+ i = endIndex + closeMatch[0].length;
268
+ }
269
+ else if (isWordCharacter(character)) {
270
+ let endIndex = i + 1;
271
+ while (isWordCharacter(text[endIndex]))
272
+ endIndex++;
273
+ sql += text.slice(i, endIndex);
274
+ i = endIndex;
275
+ }
276
+ else {
277
+ sql += character;
278
+ i++;
279
+ }
280
+ }
281
+ return { sql, values };
282
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type JoinInfo, type KickOptions, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Purchase, type Refusal, type ReserveOptions, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type SendOptions, type VersionUpdate, } from "./room.ts";
2
+ export { REQUEST_LIMITS, type RequestErrorCode, type Server } from "./requests.ts";
3
+ export { SCHEDULE_LIMITS, type Schedule, type ScheduledRun } from "./schedules.ts";
4
+ export { WEBHOOK_LIMITS, verifyWebhook, type WebhookError, type WebhookErrorCode, type WebhookEvent, type WebhookEventKind, type WebhookHeaders } from "./webhooks.ts";
5
+ export { DATABASE_LIMITS, DatabaseError, type DatabaseErrorCode, type DatabaseRow, type DatabaseStatement, type DatabaseValue, type GameDatabase } from "./database.ts";
6
+ export { OUTSIDE_CALL_LIMITS, OutsideCallError, type OutsideCallErrorCode } from "./outside-calls.ts";
7
+ export { PASS_ISSUER_URL, PASS_TOKEN_TYPE, PASS_VERSION_NUMBER, PassError, RECEIPT_ISSUER_URL, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, ReceiptError, canChat, passesAreFriends, passesHaveBlockBetween, verifyPass, verifyReceipt, type PassClaims, type PassErrorCode, type PassExpectations, type PlayerKind, type PublicKey, type ReceiptClaims, type ReceiptErrorCode, type ReceiptExpectations, type WhoCanChat } from "./pass.ts";
8
+ export { CHAT_CHECK_LIMITS, ChatCheckError, checkChat, type ChatCheck, type ChatCheckOptions, type ChatCheckReason } from "./chat.ts";
9
+ export { guestName, isRoomCode } from "./names.ts";
10
+ export { relay } from "./relay.ts";
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // What @zaisma/game-server gives developers, named one by one: from v1 it only grows (test/contracts).
2
+ // Zaisma's own parts of the package, never locked, are in ./internal.
3
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, } from "./room.js";
4
+ export { REQUEST_LIMITS } from "./requests.js";
5
+ export { SCHEDULE_LIMITS } from "./schedules.js";
6
+ export { WEBHOOK_LIMITS, verifyWebhook } from "./webhooks.js";
7
+ export { DATABASE_LIMITS, DatabaseError } from "./database.js";
8
+ export { OUTSIDE_CALL_LIMITS, OutsideCallError } from "./outside-calls.js";
9
+ export { PASS_ISSUER_URL, PASS_TOKEN_TYPE, PASS_VERSION_NUMBER, PassError, RECEIPT_ISSUER_URL, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, ReceiptError, canChat, passesAreFriends, passesHaveBlockBetween, verifyPass, verifyReceipt } from "./pass.js";
10
+ export { CHAT_CHECK_LIMITS, ChatCheckError, checkChat } from "./chat.js";
11
+ export { guestName, isRoomCode } from "./names.js";
12
+ export { relay } from "./relay.js";
@@ -0,0 +1,13 @@
1
+ export * from "./room.ts";
2
+ export * from "./room-calls.ts";
3
+ export * from "./state-sync.ts";
4
+ export * from "./database.ts";
5
+ export * from "./dump.ts";
6
+ export * from "./requests.ts";
7
+ export * from "./outside-calls.ts";
8
+ export * from "./schedules.ts";
9
+ export * from "./webhooks.ts";
10
+ export * from "./pass.ts";
11
+ export * from "./names.ts";
12
+ export * from "./chat.ts";
13
+ export * from "./relay.ts";
@@ -0,0 +1,15 @@
1
+ // Zaisma's own use of the game server package: everything in src, and what only Zaisma does (signing passes).
2
+ // Never published or locked: games and developers' servers use the package's index.
3
+ export * from "./room.js";
4
+ export * from "./room-calls.js";
5
+ export * from "./state-sync.js";
6
+ export * from "./database.js";
7
+ export * from "./dump.js";
8
+ export * from "./requests.js";
9
+ export * from "./outside-calls.js";
10
+ export * from "./schedules.js";
11
+ export * from "./webhooks.js";
12
+ export * from "./pass.js";
13
+ export * from "./names.js";
14
+ export * from "./chat.js";
15
+ export * from "./relay.js";
package/src/names.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** A friendly guest name like "Swift Fox 42". Guests can't type their own names. */
2
+ export declare function guestName(): string;
3
+ export declare function roomCode(): string;
4
+ export declare function isRoomCode(code: string): boolean;
package/src/names.js ADDED
@@ -0,0 +1,18 @@
1
+ const ADJECTIVES = ["Swift", "Brave", "Quiet", "Lucky", "Bright", "Clever", "Sunny", "Bold", "Calm", "Fuzzy", "Jolly", "Nimble", "Rapid", "Sly", "Witty", "Zany", "Cosmic", "Mighty", "Tiny", "Wild"];
2
+ const ANIMALS = ["Fox", "Otter", "Panda", "Hawk", "Lynx", "Koala", "Badger", "Falcon", "Gecko", "Moose", "Owl", "Raven", "Seal", "Tiger", "Wolf", "Yak", "Bison", "Crane", "Heron", "Newt"];
3
+ function pick(list, randomNumber) {
4
+ return list[randomNumber % list.length];
5
+ }
6
+ /** A friendly guest name like "Swift Fox 42". Guests can't type their own names. */
7
+ export function guestName() {
8
+ const [adjectiveNumber, animalNumber, endNumber] = crypto.getRandomValues(new Uint32Array(3));
9
+ return `${pick(ADJECTIVES, adjectiveNumber)} ${pick(ANIMALS, animalNumber)} ${10 + (endNumber % 90)}`;
10
+ }
11
+ /** No 0/O or 1/I, so codes read out loud or typed from a screen don't get mixed up. */
12
+ const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
13
+ export function roomCode() {
14
+ return Array.from(crypto.getRandomValues(new Uint8Array(6)), (byte) => CODE_ALPHABET[byte & 31]).join("");
15
+ }
16
+ export function isRoomCode(code) {
17
+ return /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{6}$/.test(code);
18
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Calls from a game server to other servers (game server reach): plain fetch(). On Zaisma every call
3
+ * goes through the rooms Worker's gateway (apps/rooms/src/outbound.ts), which takes only https to the
4
+ * hosts the game lists in game_server_allowed_hosts; a call it refuses throws an OutsideCallError in
5
+ * the game server with one of these codes. On the developer's own server the Node runner calls out
6
+ * freely, so the same code runs in both.
7
+ */
8
+ export type OutsideCallErrorCode =
9
+ /** Only https: addresses. */
10
+ "not_https"
11
+ /** An IP address, this computer, or Zaisma's own domains. */
12
+ | "unreachable_host"
13
+ /** A host the game doesn't list in game_server_allowed_hosts. */
14
+ | "host_not_allowed"
15
+ /** More calls a minute than a game makes in one place (OUTSIDE_CALL_LIMITS.maximumCallsPerMin). */
16
+ | "outside_call_limit"
17
+ /** A body over OUTSIDE_CALL_LIMITS.maximumBodySizeBytes. */
18
+ | "too_big"
19
+ /** No answer within OUTSIDE_CALL_LIMITS.timeoutSec. */
20
+ | "outside_call_timeout"
21
+ /** The other server couldn't be reached, like a name that doesn't exist. */
22
+ | "outside_call_failed";
23
+ export declare const OUTSIDE_CALL_LIMITS: {
24
+ readonly maximumBodySizeBytes: number;
25
+ readonly timeoutSec: 10;
26
+ readonly maximumCallsPerMin: 600;
27
+ };
28
+ export declare const OUTSIDE_CALL_ERROR_CODES: readonly string[];
29
+ /** A call from a game server to another server that Zaisma's gateway refused (or that failed), with a code, what happened and what to do. */
30
+ export declare class OutsideCallError extends Error {
31
+ readonly code: OutsideCallErrorCode;
32
+ readonly fix: string;
33
+ constructor(code: OutsideCallErrorCode, message: string, fix: string);
34
+ /** True for any error named OutsideCallError with a known code, so instanceof works for the copy a game's sandbox has too. */
35
+ static [Symbol.hasInstance](candidate: unknown): boolean;
36
+ }
37
+ /** What a refused call answers inside the sandbox, which its fetch() turns into an OutsideCallError. */
38
+ export interface OutsideCallRefusal {
39
+ error: {
40
+ code: OutsideCallErrorCode;
41
+ message: string;
42
+ fix: string;
43
+ };
44
+ }
45
+ /** The header a refusal comes back with, so the sandbox can tell it from a server's own answer. */
46
+ export declare const OUTSIDE_CALL_REFUSAL_HEADER = "zaisma-outside-call-refused";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Calls from a game server to other servers (game server reach): plain fetch(). On Zaisma every call
3
+ * goes through the rooms Worker's gateway (apps/rooms/src/outbound.ts), which takes only https to the
4
+ * hosts the game lists in game_server_allowed_hosts; a call it refuses throws an OutsideCallError in
5
+ * the game server with one of these codes. On the developer's own server the Node runner calls out
6
+ * freely, so the same code runs in both.
7
+ */
8
+ export const OUTSIDE_CALL_LIMITS = {
9
+ maximumBodySizeBytes: 5 * 1024 * 1024,
10
+ timeoutSec: 10,
11
+ maximumCallsPerMin: 600,
12
+ };
13
+ export const OUTSIDE_CALL_ERROR_CODES = ["not_https", "unreachable_host", "host_not_allowed", "outside_call_limit", "too_big", "outside_call_timeout", "outside_call_failed"];
14
+ /** A call from a game server to another server that Zaisma's gateway refused (or that failed), with a code, what happened and what to do. */
15
+ export class OutsideCallError extends Error {
16
+ code;
17
+ fix;
18
+ constructor(code, message, fix) {
19
+ super(`${message} ${fix} (${code})`);
20
+ this.name = "OutsideCallError";
21
+ this.code = code;
22
+ this.fix = fix;
23
+ }
24
+ /** True for any error named OutsideCallError with a known code, so instanceof works for the copy a game's sandbox has too. */
25
+ static [Symbol.hasInstance](candidate) {
26
+ return candidate instanceof Error && candidate.name === "OutsideCallError" && OUTSIDE_CALL_ERROR_CODES.includes(candidate.code);
27
+ }
28
+ }
29
+ /** The header a refusal comes back with, so the sandbox can tell it from a server's own answer. */
30
+ export const OUTSIDE_CALL_REFUSAL_HEADER = "zaisma-outside-call-refused";