@xgamesfun/xcore 0.1.0 → 0.2.1
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/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/server/contract.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// gf-server-v1: what a game's own server code is, as the kit reads it. This one module holds every name and number of
|
|
2
|
-
// the contract between a game's `server/`, the kit (dev, publish) and
|
|
2
|
+
// the contract between a game's `server/`, the kit (dev, publish) and xgames.fun's hosting (the play edge's /api/*, the
|
|
3
3
|
// dispatch namespace, the platform binding env.GF). docs/SERVER.md is the person's page for it.
|
|
4
4
|
//
|
|
5
|
-
// Coded to
|
|
5
|
+
// Coded to xgames.fun's hosting contract, version gf-server-v1, and the play edge's own checks of a server's manifest.
|
|
6
6
|
// Where that contract changes, change it here: nothing else in the kit names these values.
|
|
7
7
|
//
|
|
8
8
|
// A game folder:
|
|
@@ -12,22 +12,27 @@
|
|
|
12
12
|
// "routes": "/api/*", "compatibilityFlags": [] } } (§2). Without it, server/index.js is the
|
|
13
13
|
// server when it exists, with no objects.
|
|
14
14
|
// server/ and gamesfun.json are never served; a game with a server has no files under api/ (§1).
|
|
15
|
+
// gamesfun.json { "database": { "engine": "d1", "binding": "DB", "migrations": "server/migrations" } }: a D1
|
|
16
|
+
// database of the game's own (xgames.fun's STORAGE.md §8.2, readDatabaseConfig below). A
|
|
17
|
+
// database and no server of its own: xcore's stock server is the server.
|
|
15
18
|
// Requests: <the game's origin>/api/* → the server's fetch, the URL unchanged; every x-gf-* header removed (§4.1)
|
|
16
19
|
// (in the kit's dev server: the page's /game/api/*, sent on as /api/*)
|
|
17
|
-
// env: env.GF the platform (below); no other secret of
|
|
20
|
+
// env: env.GF the platform (below); no other secret of xgames.fun's
|
|
18
21
|
// env.<binding> a Durable Object namespace for each declared object, SQLite storage
|
|
19
22
|
// env.GF_GAME_ID, env.GF_SLUG, env.GF_CONTRACT plain text
|
|
23
|
+
// env.<database.binding> the game's D1 database, when gamesfun.json has a database (env.DB)
|
|
20
24
|
// Runtime: Workers, compatibility date 2025-06-01; nodejs_compat only when gamesfun.json asks for it
|
|
21
|
-
// Outbound: fetch() reaches only backend.json's origins and
|
|
25
|
+
// Outbound: fetch() reaches only backend.json's origins and xgames.fun's (hosted; not enforced on this machine)
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
22
27
|
import { existsSync } from 'node:fs';
|
|
23
|
-
import { readFile } from 'node:fs/promises';
|
|
28
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
24
29
|
import path from 'node:path';
|
|
25
30
|
|
|
26
31
|
export const SERVER_FORMAT = 'gf-server-v1';
|
|
27
32
|
export const CONFIG_FILE = 'gamesfun.json';
|
|
28
33
|
export const DEFAULT_ENTRY = 'server/index.js';
|
|
29
34
|
export const SERVER_DIR = 'server';
|
|
30
|
-
/** The runtime the hosted script runs on (
|
|
35
|
+
/** The runtime the hosted script runs on (xgames.fun's upload and its local parity use these). */
|
|
31
36
|
export const COMPATIBILITY_DATE = '2025-06-01';
|
|
32
37
|
/** The only compatibility flag a game may ask for (gamesfun.json server.compatibilityFlags). */
|
|
33
38
|
export const ALLOWED_FLAGS = Object.freeze(['nodejs_compat']);
|
|
@@ -36,7 +41,7 @@ export const ROUTES = '/api/*';
|
|
|
36
41
|
export const SERVER_KEYS = Object.freeze(['main', 'objects', 'routes', 'compatibilityFlags']);
|
|
37
42
|
/** One module goes up: the bundle. */
|
|
38
43
|
export const BUNDLE_NAME = 'index.js';
|
|
39
|
-
/**
|
|
44
|
+
/** xgames.fun's caps for one game's server (§7: 16 modules and 5 MB in all, 8 Durable Object classes). */
|
|
40
45
|
export const LIMITS = Object.freeze({
|
|
41
46
|
bytes: 5 * 1024 * 1024,
|
|
42
47
|
warnBytes: 1024 * 1024,
|
|
@@ -92,10 +97,20 @@ export const NODE_REFUSED = Object.freeze([
|
|
|
92
97
|
* scores.top(board, {limit?, offset?}) → {ok, order, writer, entries: [{rank, player, name, score, at}]}
|
|
93
98
|
* scores.best(board, player) → {ok, entry: {rank, score, name, at} | null}
|
|
94
99
|
* scores.remove(board, player) → {ok, removed}
|
|
95
|
-
* loot.report({round, endedAt, winners: [{wallet, place, grant?}]}) → {ok, result, vouchers} (
|
|
100
|
+
* loot.report({round, endedAt, winners: [{wallet, place, grant?}]}) → {ok, result, vouchers} (xgames.fun signs it
|
|
96
101
|
* with the game's host key; the keeper decides) — locally logged only
|
|
97
102
|
* loot.status() → {ok, host, certified, game}
|
|
98
103
|
* info() → {ok, contract, gameId, slug}
|
|
104
|
+
* files.put(path, body, {type, public = false, length}) → {ok, path, bytes, url?} (the game's project files,
|
|
105
|
+
* STORAGE.md §8.6, §8.10: body a string, ArrayBuffer or byte ReadableStream, up
|
|
106
|
+
* to 25 MB; a stream held to exactly its length, or up to 1 MB without; url,
|
|
107
|
+
* https://<slug>.xgamesusercontent.com/<path>, when public; over the cap
|
|
108
|
+
* {ok: false, code: 'full'})
|
|
109
|
+
* files.get(path) → {ok, body, type, bytes, public} (body: a ReadableStream of the bytes)
|
|
110
|
+
* files.head(path) → {ok, type, bytes, public}
|
|
111
|
+
* files.list({prefix, after, limit = 100}) → {ok, items: [{path, bytes, type, public, updatedAt}], after}
|
|
112
|
+
* files.delete(path) → {ok, deleted}
|
|
113
|
+
* files.usage() → {ok, bytes, files, cap}
|
|
99
114
|
*/
|
|
100
115
|
export const GF_METHODS = Object.freeze([
|
|
101
116
|
'verifyPlayer',
|
|
@@ -112,8 +127,79 @@ export const GF_METHODS = Object.freeze([
|
|
|
112
127
|
'loot.report',
|
|
113
128
|
'loot.status',
|
|
114
129
|
'info',
|
|
130
|
+
'files.put',
|
|
131
|
+
'files.get',
|
|
132
|
+
'files.head',
|
|
133
|
+
'files.list',
|
|
134
|
+
'files.delete',
|
|
135
|
+
'files.usage',
|
|
115
136
|
]);
|
|
116
|
-
|
|
137
|
+
|
|
138
|
+
// ---- the game's project files: env.GF.files and the files domain (STORAGE.md §8.6–8.7) ---------------------------
|
|
139
|
+
// Files the developer or the game's server writes, never players (replays, generated levels, content packs). Live:
|
|
140
|
+
// the bucket xgames-files, key g/<gameId>/<path>; a public one served at https://<slug>.xgamesusercontent.com/<path>,
|
|
141
|
+
// another site to the browser. On this machine: <game>/.gamesfun/dev/files/, served on a port of its own (another
|
|
142
|
+
// origin, though the same site as the page's)
|
|
143
|
+
// (../dev/files.mjs).
|
|
144
|
+
|
|
145
|
+
/** The files' rules and caps (§8.6; the cap is Free's: Creator 10 GB, Scale 100 GB). */
|
|
146
|
+
export const FILES = Object.freeze({
|
|
147
|
+
path: /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/,
|
|
148
|
+
pathChars: 512,
|
|
149
|
+
fileBytes: 25 * 1024 * 1024,
|
|
150
|
+
capBytes: 100 * 1024 * 1024,
|
|
151
|
+
listMax: 1000,
|
|
152
|
+
domain: 'xgamesusercontent.com',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** A file's path, checked: letters, digits, . _ - and / between them, no . or .. part, at most 512. → null or why */
|
|
156
|
+
export function filePathProblem(p) {
|
|
157
|
+
if (typeof p !== 'string' || !p) return 'A file needs a path, such as replays/2026-11-08.json.';
|
|
158
|
+
if (p.length > FILES.pathChars) return `A file's path is at most ${FILES.pathChars} characters.`;
|
|
159
|
+
if (!FILES.path.test(p)) return 'A path is letters, digits, ".", "_" and "-", with "/" between folders.';
|
|
160
|
+
if (p.split('/').some(part => part === '.' || part === '..')) return 'A path has no "." or ".." part.';
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The types served as themselves (§8.7); anything else goes as application/octet-stream, to be downloaded. */
|
|
165
|
+
const INLINE =
|
|
166
|
+
/^(image\/(png|jpeg|webp|gif|avif)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+|font\/[a-z0-9.+-]+|application\/json|text\/plain)$/;
|
|
167
|
+
/** How the files domain serves a file of this type. → {type, inline} */
|
|
168
|
+
export function servedType(type) {
|
|
169
|
+
const t = String(type ?? '')
|
|
170
|
+
.split(';')[0]
|
|
171
|
+
.trim()
|
|
172
|
+
.toLowerCase();
|
|
173
|
+
return INLINE.test(t)
|
|
174
|
+
? { type: t === 'text/plain' ? 'text/plain; charset=utf-8' : t, inline: true }
|
|
175
|
+
: { type: 'application/octet-stream', inline: false };
|
|
176
|
+
}
|
|
177
|
+
const BY_EXTENSION = {
|
|
178
|
+
png: 'image/png',
|
|
179
|
+
jpg: 'image/jpeg',
|
|
180
|
+
jpeg: 'image/jpeg',
|
|
181
|
+
webp: 'image/webp',
|
|
182
|
+
gif: 'image/gif',
|
|
183
|
+
avif: 'image/avif',
|
|
184
|
+
mp3: 'audio/mpeg',
|
|
185
|
+
ogg: 'audio/ogg',
|
|
186
|
+
wav: 'audio/wav',
|
|
187
|
+
m4a: 'audio/mp4',
|
|
188
|
+
mp4: 'video/mp4',
|
|
189
|
+
webm: 'video/webm',
|
|
190
|
+
woff2: 'font/woff2',
|
|
191
|
+
woff: 'font/woff',
|
|
192
|
+
ttf: 'font/ttf',
|
|
193
|
+
otf: 'font/otf',
|
|
194
|
+
json: 'application/json',
|
|
195
|
+
txt: 'text/plain',
|
|
196
|
+
};
|
|
197
|
+
/** A file's type from its name, when the put names none. */
|
|
198
|
+
export const fileType = p => BY_EXTENSION[/\.([a-z0-9]+)$/i.exec(p)?.[1]?.toLowerCase()] ?? 'application/octet-stream';
|
|
199
|
+
|
|
200
|
+
/** Server code that calls env.GF.files (its sources, comments left out), which needs "files" in server.features. */
|
|
201
|
+
export const usesFiles = code => /\bGF\s*\.\s*files\b|\bfiles\b[^;{}]*\}\s*=\s*[\w.]*\bGF\b/.test(code);
|
|
202
|
+
/** The platform's per-game limits (xgames.fun platform/limits.mjs), the same here so a game fits both. */
|
|
117
203
|
export const PLATFORM_LIMITS = Object.freeze({
|
|
118
204
|
saves: Object.freeze({ playerChars: 128, valueBytes: 64 * 1024, keysPerPlayer: 16 }),
|
|
119
205
|
scores: Object.freeze({ boards: 16, rules: 32, nameChars: 32, perPlayerEveryMs: 1000, topMax: 100 }),
|
|
@@ -123,7 +209,7 @@ export const PLATFORM_LIMITS = Object.freeze({
|
|
|
123
209
|
export const SAVE_KEY = /^[A-Za-z0-9_.-]{1,32}$/;
|
|
124
210
|
const ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
125
211
|
const ROUND = /^[0-9a-f]{32}$/;
|
|
126
|
-
/** A loot result's shape, as
|
|
212
|
+
/** A loot result's shape, as xgames.fun checks it (platform/loot.mjs checkResult, less the owner's grant list). → null or a refusal */
|
|
127
213
|
export function checkLootResult(r, now = Date.now()) {
|
|
128
214
|
const no = (code, why) => ({ ok: false, code, why });
|
|
129
215
|
if (!r || typeof r !== 'object') return no('shape', 'A result is {round, endedAt, winners}.');
|
|
@@ -143,7 +229,7 @@ export function checkLootResult(r, now = Date.now()) {
|
|
|
143
229
|
}
|
|
144
230
|
|
|
145
231
|
/**
|
|
146
|
-
* Does this
|
|
232
|
+
* Does this xgames.fun take server code? The contract (§6) does not say how a kit learns it, so the kit asks GET
|
|
147
233
|
* /api/kit/me for `server: { contracts: ['gf-server-v1'], maxBytes? }` (also read: `formats`, `contract`) and sends
|
|
148
234
|
* server code only when it is there. → { ok, maxBytes } | { ok: false, why }
|
|
149
235
|
*/
|
|
@@ -155,17 +241,25 @@ export function studioAccepts(me) {
|
|
|
155
241
|
if (!formats.includes(SERVER_FORMAT))
|
|
156
242
|
return {
|
|
157
243
|
ok: false,
|
|
158
|
-
why: `this
|
|
244
|
+
why: `this xgames.fun does not take server code yet (GET /api/kit/me does not list ${SERVER_FORMAT})`,
|
|
159
245
|
};
|
|
160
246
|
const maxBytes = Number.isFinite(s.maxBytes) && s.maxBytes > 0 ? Math.min(s.maxBytes, LIMITS.bytes) : LIMITS.bytes;
|
|
161
247
|
return { ok: true, maxBytes };
|
|
162
248
|
}
|
|
163
249
|
|
|
250
|
+
/**
|
|
251
|
+
* What else this xgames.fun runs for a game (STORAGE.md §8.1): GET /api/kit/me → server.features, a list that has
|
|
252
|
+
* "d1" once it makes each game a database, and "files" once env.GF.files and the files domain are live. → a Set
|
|
253
|
+
*/
|
|
254
|
+
export const studioFeatures = me =>
|
|
255
|
+
new Set(Array.isArray(me?.server?.features) ? me.server.features.filter(f => typeof f === 'string') : []);
|
|
256
|
+
|
|
164
257
|
/**
|
|
165
258
|
* The publish body's `server` field (POST /api/kit/publish), the manifest of §6.1. The module's bytes go up through
|
|
166
|
-
* the same blob route as the files (POST /api/kit/publish/<id>/blobs/<sha256>), named in the answer's `missing
|
|
259
|
+
* the same blob route as the files (POST /api/kit/publish/<id>/blobs/<sha256>), named in the answer's `missing`; so
|
|
260
|
+
* do the database's migration files (STORAGE.md §8.3: `database: {binding, migrations: [{name, sha256, bytes}]}`).
|
|
167
261
|
*/
|
|
168
|
-
export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm = [] }) {
|
|
262
|
+
export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm = [], database = null }) {
|
|
169
263
|
return {
|
|
170
264
|
contract: SERVER_FORMAT,
|
|
171
265
|
main: BUNDLE_NAME,
|
|
@@ -176,9 +270,317 @@ export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm
|
|
|
176
270
|
objects: objects.map(o => ({ class: o.class, binding: o.binding })),
|
|
177
271
|
compatibilityDate: COMPATIBILITY_DATE,
|
|
178
272
|
compatibilityFlags: [...compatibilityFlags],
|
|
273
|
+
...(database?.has
|
|
274
|
+
? {
|
|
275
|
+
database: {
|
|
276
|
+
binding: database.binding,
|
|
277
|
+
migrations: database.migrations.map(m => ({ name: m.name, sha256: m.sha256, bytes: m.bytes })),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
: {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- the game's database: gamesfun.json → database (STORAGE.md §8.2) ---------------------------------------------
|
|
285
|
+
// "database": { "engine": "d1", "binding": "DB", "migrations": "server/migrations",
|
|
286
|
+
// "collections": { "*": {"read": "owner", "write": "owner"} } }
|
|
287
|
+
// One D1 database a game, bound to its server as env.<binding>. Its tables come from the migration files, applied in
|
|
288
|
+
// name order, each once; once applied a migration never changes (append only). `collections` are the rules of the
|
|
289
|
+
// page's documents (gf.db, api/db/*): a game with a database and no server/ gets xcore's stock server
|
|
290
|
+
// (../client/server/stock.js), which serves them from the database; a game with a server of its own serves them with
|
|
291
|
+
// server/gf-docs.js (`xcore add` writes it), and publish refuses one whose server does not route api/db/.
|
|
292
|
+
|
|
293
|
+
/** The keys gamesfun.json → database takes. */
|
|
294
|
+
export const DATABASE_KEYS = Object.freeze(['engine', 'binding', 'migrations', 'collections']);
|
|
295
|
+
export const DATABASE_DEFAULTS = Object.freeze({ engine: 'd1', binding: 'DB', migrations: 'server/migrations' });
|
|
296
|
+
/** A migration's file name: four digits, then words (0001_init.sql). */
|
|
297
|
+
export const MIGRATION_NAME = /^\d{4}_[a-z0-9_-]+\.sql$/;
|
|
298
|
+
/**
|
|
299
|
+
* The most a request through xgames.fun's Studio may carry (§8.10): it runs on Vercel, which caps a request body at
|
|
300
|
+
* 4.5 MB. An owner's `db import --live` and `files put --live` are held to it; a bigger file goes up from the game's
|
|
301
|
+
* server (env.GF.files.put, up to 25 MB).
|
|
302
|
+
*/
|
|
303
|
+
export const STUDIO_BODY_BYTES = 4_500_000;
|
|
304
|
+
/** xgames.fun's caps (§8.3, §8.5): 200 migrations of 1 MB each; an import of 5 MB. */
|
|
305
|
+
export const DATABASE_LIMITS = Object.freeze({
|
|
306
|
+
migrations: 200,
|
|
307
|
+
migrationBytes: 1024 * 1024,
|
|
308
|
+
importBytes: 5 * 1024 * 1024,
|
|
309
|
+
// D1's longest SQL statement (developers.cloudflare.com/d1/platform/limits); each statement of a migration is held
|
|
310
|
+
// to it on its own (§8.10), so a migration may still be 1 MB of many statements
|
|
311
|
+
statementBytes: 100_000,
|
|
312
|
+
});
|
|
313
|
+
/** In server/database.js since 0.2.0 (its table's key): every player a space of their own; 0.1.0's had (collection, id). */
|
|
314
|
+
export const OWNER_SPACES = 'PRIMARY KEY (collection, owner, id)';
|
|
315
|
+
/** RULES as xcore 0.1.0 wrote it into server/database.js, untouched: anyone read every document. */
|
|
316
|
+
export const OLD_DEFAULT_RULES = "export const RULES = {\n '*': { read: 'anyone', write: 'owner' },\n};";
|
|
317
|
+
/** The collections' rules when the stock server has none: each player reads and writes only their own. */
|
|
318
|
+
export const DEFAULT_COLLECTIONS = Object.freeze({ '*': Object.freeze({ read: 'owner', write: 'owner' }) });
|
|
319
|
+
/** What the stock server is called where a server's entry file would be named. */
|
|
320
|
+
export const STOCK_ENTRY = "xcore's stock server";
|
|
321
|
+
const COLLECTION = /^[A-Za-z0-9_-]{1,64}$/;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The database block, checked (§8.2). `objects`: the server's Durable Objects, whose bindings it must not take.
|
|
325
|
+
* → { has, engine, binding, migrationsDir, migrations: [{name, path, sha256, bytes, data, text}], collections, problems,
|
|
326
|
+
* warnings }. `has` is false without a block.
|
|
327
|
+
*/
|
|
328
|
+
export async function readDatabaseConfig(dir, config, { objects = [], own = false } = {}) {
|
|
329
|
+
const problems = [],
|
|
330
|
+
warnings = [];
|
|
331
|
+
const d = config?.database;
|
|
332
|
+
const none = { has: false, binding: null, migrations: [], collections: null, problems, warnings };
|
|
333
|
+
if (d === undefined) return none;
|
|
334
|
+
if (d === null || typeof d !== 'object' || Array.isArray(d)) {
|
|
335
|
+
problems.push(`${CONFIG_FILE} "database" is an object: {"engine": "d1", "binding": "DB"}`);
|
|
336
|
+
return none;
|
|
337
|
+
}
|
|
338
|
+
const extra = Object.keys(d).filter(k => !DATABASE_KEYS.includes(k));
|
|
339
|
+
if (extra.length)
|
|
340
|
+
problems.push(
|
|
341
|
+
`${CONFIG_FILE} database has ${extra.map(k => JSON.stringify(k)).join(', ')}, which it does not know (it takes ${DATABASE_KEYS.join(', ')})`,
|
|
342
|
+
);
|
|
343
|
+
const engine = d.engine ?? DATABASE_DEFAULTS.engine;
|
|
344
|
+
if (engine !== 'd1') problems.push(`${CONFIG_FILE} database.engine is "d1" (the only engine xgames.fun runs)`);
|
|
345
|
+
const binding = d.binding ?? DATABASE_DEFAULTS.binding;
|
|
346
|
+
if (typeof binding !== 'string' || !BINDING_NAME.test(binding) || reservedBinding(binding))
|
|
347
|
+
problems.push(
|
|
348
|
+
`${CONFIG_FILE} database.binding ${JSON.stringify(binding)} must be A–Z, 0–9 and _, starting with a letter, at most 32 characters, and not GF or GF_* (it is env.<binding> in the server; the default is "DB")`,
|
|
349
|
+
);
|
|
350
|
+
else if (objects.some(o => o.binding === binding))
|
|
351
|
+
problems.push(
|
|
352
|
+
`${CONFIG_FILE} database.binding ${binding} is also a Durable Object's binding (server.objects): give one of them another name`,
|
|
353
|
+
);
|
|
354
|
+
// the migrations stay inside server/, which is never a public file
|
|
355
|
+
const folder =
|
|
356
|
+
typeof d.migrations === 'string' ? d.migrations.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') : null;
|
|
357
|
+
let migrationsDir = DATABASE_DEFAULTS.migrations;
|
|
358
|
+
if (
|
|
359
|
+
d.migrations !== undefined &&
|
|
360
|
+
(folder === null || !/^server\/[A-Za-z0-9_./-]+$/.test(folder) || folder.split('/').includes('..'))
|
|
361
|
+
)
|
|
362
|
+
problems.push(
|
|
363
|
+
`${CONFIG_FILE} database.migrations is a folder inside server/, such as "server/migrations" (server/ is never a public file)`,
|
|
364
|
+
);
|
|
365
|
+
else if (folder !== null) migrationsDir = folder;
|
|
366
|
+
const migrations = [];
|
|
367
|
+
const abs = path.join(dir, ...migrationsDir.split('/'));
|
|
368
|
+
if (existsSync(abs)) {
|
|
369
|
+
const names = (await readdir(abs, { withFileTypes: true }))
|
|
370
|
+
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
|
371
|
+
.map(e => e.name)
|
|
372
|
+
.sort();
|
|
373
|
+
const seen = new Map();
|
|
374
|
+
for (const name of names) {
|
|
375
|
+
if (!MIGRATION_NAME.test(name)) {
|
|
376
|
+
if (/\.sql$/i.test(name))
|
|
377
|
+
problems.push(
|
|
378
|
+
`${migrationsDir}/${name}: a migration's name is four digits, _ and lowercase words, such as 0002_add_guilds.sql`,
|
|
379
|
+
);
|
|
380
|
+
else warnings.push(`${migrationsDir}/${name} is not a migration (NNNN_name.sql): it is left out`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const n = name.slice(0, 4);
|
|
384
|
+
if (seen.has(n)) {
|
|
385
|
+
problems.push(`${migrationsDir}: ${seen.get(n)} and ${name} have the same number; give each its own`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
seen.set(n, name);
|
|
389
|
+
const bytes = await readFile(path.join(abs, name));
|
|
390
|
+
const empty = emptyMigration({ name, path: `${migrationsDir}/${name}`, bytes: bytes.length });
|
|
391
|
+
if (empty) problems.push(empty);
|
|
392
|
+
else if (bytes.length > DATABASE_LIMITS.migrationBytes)
|
|
393
|
+
problems.push(`${migrationsDir}/${name} is ${(bytes.length / 1048576).toFixed(1)} MB; a migration may be 1 MB`);
|
|
394
|
+
else {
|
|
395
|
+
// xgames.fun refuses it at publish (422 migration-failed), with nothing of the publish's migrations applied
|
|
396
|
+
const why = migrationStatementsProblem({ name, text: bytes.toString('utf8') });
|
|
397
|
+
if (why) problems.push(`${migrationsDir}/${name}: ${why}. xgames.fun would refuse it (migration-failed)`);
|
|
398
|
+
}
|
|
399
|
+
migrations.push({
|
|
400
|
+
name,
|
|
401
|
+
path: `${migrationsDir}/${name}`,
|
|
402
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
403
|
+
bytes: bytes.length,
|
|
404
|
+
data: bytes,
|
|
405
|
+
text: bytes.toString('utf8'),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (migrations.length > DATABASE_LIMITS.migrations)
|
|
409
|
+
problems.push(
|
|
410
|
+
`${migrationsDir} has ${migrations.length} migrations; a game may have ${DATABASE_LIMITS.migrations}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
let collections = null;
|
|
414
|
+
if (d.collections !== undefined) {
|
|
415
|
+
if (!d.collections || typeof d.collections !== 'object' || Array.isArray(d.collections))
|
|
416
|
+
problems.push(
|
|
417
|
+
`${CONFIG_FILE} database.collections is an object: {"*": {"read": "owner", "write": "owner"}, "profiles": {"read": "anyone", "write": "owner"}}`,
|
|
418
|
+
);
|
|
419
|
+
else {
|
|
420
|
+
collections = {};
|
|
421
|
+
for (const [name, rule] of Object.entries(d.collections)) {
|
|
422
|
+
if (name !== '*' && !COLLECTION.test(name)) {
|
|
423
|
+
problems.push(
|
|
424
|
+
`${CONFIG_FILE} database.collections: ${JSON.stringify(name)} is not a collection's name (1 to 64 letters, digits, - or _; or "*")`,
|
|
425
|
+
);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const keys = rule && typeof rule === 'object' && !Array.isArray(rule) ? Object.keys(rule) : null;
|
|
429
|
+
if (
|
|
430
|
+
!keys ||
|
|
431
|
+
keys.some(k => k !== 'read' && k !== 'write') ||
|
|
432
|
+
(rule.read !== undefined && !['owner', 'anyone'].includes(rule.read)) ||
|
|
433
|
+
(rule.write !== undefined && !['owner', 'server'].includes(rule.write))
|
|
434
|
+
) {
|
|
435
|
+
problems.push(
|
|
436
|
+
`${CONFIG_FILE} database.collections.${name} is {"read": "owner" | "anyone", "write": "owner" | "server"}`,
|
|
437
|
+
);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
collections[name] = { ...(rule.read ? { read: rule.read } : {}), ...(rule.write ? { write: rule.write } : {}) };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
has: true,
|
|
446
|
+
engine,
|
|
447
|
+
binding: typeof binding === 'string' ? binding : DATABASE_DEFAULTS.binding,
|
|
448
|
+
migrationsDir,
|
|
449
|
+
migrations,
|
|
450
|
+
// the stock server's rules, or a server of its own's when it has them (server/gf-docs.js reads them)
|
|
451
|
+
collections: own ? collections : (collections ?? { ...DEFAULT_COLLECTIONS }),
|
|
452
|
+
problems,
|
|
453
|
+
warnings,
|
|
179
454
|
};
|
|
180
455
|
}
|
|
181
456
|
|
|
457
|
+
/**
|
|
458
|
+
* The SQL's statements, split on semicolons outside strings, quoted names and comments, with the comments removed.
|
|
459
|
+
* → [statement text, trimmed] (empty statements dropped). An unterminated string or comment → null. With `triggers`, a
|
|
460
|
+
* trigger's BEGIN … END is one statement. Copied from xgames.fun's creator-studio/lib/game-database.mjs as it is: the
|
|
461
|
+
* query route's rule (../db.mjs queryProblem) and a migration's statements as Studio sends them to D1.
|
|
462
|
+
*/
|
|
463
|
+
export function sqlStatements(sql, { triggers = false } = {}) {
|
|
464
|
+
const out = [];
|
|
465
|
+
let cur = '',
|
|
466
|
+
i = 0,
|
|
467
|
+
head = [],
|
|
468
|
+
depth = 0;
|
|
469
|
+
const n = sql.length;
|
|
470
|
+
// A trigger's body (CREATE [TEMP] TRIGGER … BEGIN …; …; END) is one statement whose own statements end in `;`, read
|
|
471
|
+
// as SQLite reads it (and as ../dev/d1.mjs splitSql does): BEGIN, and a CASE inside the body, open what END closes.
|
|
472
|
+
const trigger = () =>
|
|
473
|
+
head[0] === 'CREATE' &&
|
|
474
|
+
(head[1] === 'TRIGGER' || ((head[1] === 'TEMP' || head[1] === 'TEMPORARY') && head[2] === 'TRIGGER'));
|
|
475
|
+
while (i < n) {
|
|
476
|
+
const c = sql[i],
|
|
477
|
+
d = sql[i + 1];
|
|
478
|
+
if (c === '-' && d === '-') {
|
|
479
|
+
const e = sql.indexOf('\n', i);
|
|
480
|
+
i = e < 0 ? n : e + 1;
|
|
481
|
+
cur += ' ';
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (c === '/' && d === '*') {
|
|
485
|
+
const e = sql.indexOf('*/', i + 2);
|
|
486
|
+
if (e < 0) return null;
|
|
487
|
+
i = e + 2;
|
|
488
|
+
cur += ' ';
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (c === "'" || c === '"' || c === '`' || c === '[') {
|
|
492
|
+
const close = c === '[' ? ']' : c;
|
|
493
|
+
let j = i + 1;
|
|
494
|
+
for (;;) {
|
|
495
|
+
if (j >= n) return null;
|
|
496
|
+
if (sql[j] === close) {
|
|
497
|
+
if (close !== ']' && sql[j + 1] === close) {
|
|
498
|
+
j += 2;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
j++;
|
|
504
|
+
}
|
|
505
|
+
cur += sql.slice(i, j + 1);
|
|
506
|
+
i = j + 1;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (triggers && /[A-Za-z_]/.test(c) && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '')) {
|
|
510
|
+
const w = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(sql.slice(i, i + 64))[0],
|
|
511
|
+
W = w.toUpperCase();
|
|
512
|
+
if (head.length < 3) head.push(W);
|
|
513
|
+
if (trigger()) {
|
|
514
|
+
if (W === 'BEGIN' || (W === 'CASE' && depth > 0)) depth++;
|
|
515
|
+
else if (W === 'END' && depth > 0) depth--;
|
|
516
|
+
}
|
|
517
|
+
cur += w;
|
|
518
|
+
i += w.length;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (c === ';' && depth === 0) {
|
|
522
|
+
if (cur.trim()) out.push(cur.trim());
|
|
523
|
+
cur = '';
|
|
524
|
+
head = [];
|
|
525
|
+
i++;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
cur += c;
|
|
529
|
+
i++;
|
|
530
|
+
}
|
|
531
|
+
if (cur.trim()) out.push(cur.trim());
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const clip = s => {
|
|
536
|
+
const t = s.replace(/\s+/g, ' ');
|
|
537
|
+
return t.length > 80 ? `${t.slice(0, 80)}…` : t;
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* A migration D1 cannot run as it is, in xgames.fun's words (§8.10): SQL that never ends, or a statement over D1's
|
|
541
|
+
* 100,000 bytes (UTF-8, as Studio sends it: comments left out). `m`: {name, text}. → null, or why (without an end mark)
|
|
542
|
+
*/
|
|
543
|
+
export function migrationStatementsProblem(m) {
|
|
544
|
+
const statements = sqlStatements(m.text, { triggers: true });
|
|
545
|
+
if (!statements) return `${m.name} has a string, a quoted name or a comment that never ends`;
|
|
546
|
+
for (const [i, st] of statements.entries()) {
|
|
547
|
+
const size = Buffer.byteLength(st);
|
|
548
|
+
if (size > DATABASE_LIMITS.statementBytes)
|
|
549
|
+
return `Statement ${i + 1} of ${m.name} (${clip(st)}) is ${size.toLocaleString('en-US')} bytes, and D1 runs at most ${DATABASE_LIMITS.statementBytes.toLocaleString('en-US')} bytes in one statement. Split it (an INSERT of many rows into several INSERTs, each under the limit)`;
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A migration file with nothing in it (0 bytes) is refused, here as on xgames.fun. → null, or why */
|
|
555
|
+
export const emptyMigration = m =>
|
|
556
|
+
m.bytes === 0
|
|
557
|
+
? `${m.path ?? m.name} is empty (0 bytes): put the migration's statements in it, or delete the file`
|
|
558
|
+
: null;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Do the migrations still begin with the ones already applied, each the same bytes (append only, §8.4.2)? `applied`:
|
|
562
|
+
* [{name, sha256}] in the order they were applied. → null, or {code, name, why} in plain words.
|
|
563
|
+
*/
|
|
564
|
+
export function migrationsChanged(migrations, applied = []) {
|
|
565
|
+
for (let i = 0; i < applied.length; i++) {
|
|
566
|
+
const was = applied[i],
|
|
567
|
+
now = migrations[i];
|
|
568
|
+
if (now?.name === was.name && now.sha256 === was.sha256) continue;
|
|
569
|
+
const there = migrations.find(m => m.name === was.name);
|
|
570
|
+
const why = !there
|
|
571
|
+
? `${was.name} was applied to the database, and it is not there any more`
|
|
572
|
+
: there.sha256 !== was.sha256
|
|
573
|
+
? `${was.name} changed after it was applied to the database`
|
|
574
|
+
: `${now?.name ?? 'a migration'} comes before ${was.name}, which was applied already`;
|
|
575
|
+
return {
|
|
576
|
+
code: 'migration-changed',
|
|
577
|
+
name: was.name,
|
|
578
|
+
why: `${why}. A migration is never changed once applied: put it back as it was, and make the change in a new migration after the last one`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
182
584
|
/**
|
|
183
585
|
* The game's server settings (§2). → { has, entry (relative, posix), objects: [{class, binding}], compatibilityFlags,
|
|
184
586
|
* problems, config (gamesfun.json or null) }. `has` is false for a game without server code (no gamesfun.json
|
|
@@ -205,8 +607,25 @@ export async function readServerConfig(dir) {
|
|
|
205
607
|
`${CONFIG_FILE} "server" is an object: {"main": "server/index.js", "objects": [{"class": "Room", "binding": "ROOM"}]}`,
|
|
206
608
|
);
|
|
207
609
|
const srv = s && typeof s === 'object' && !Array.isArray(s) ? s : {};
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
610
|
+
const own = s !== undefined || existsSync(path.join(dir, DEFAULT_ENTRY));
|
|
611
|
+
if (!own) {
|
|
612
|
+
// a database and no server of its own: xcore's stock server serves the page's api/db/ from it
|
|
613
|
+
const database = await readDatabaseConfig(dir, config);
|
|
614
|
+
problems.push(...database.problems);
|
|
615
|
+
if (!database.has)
|
|
616
|
+
return { has: false, entry: null, objects: [], compatibilityFlags: [], problems, warnings: [], database, config };
|
|
617
|
+
return {
|
|
618
|
+
has: true,
|
|
619
|
+
stock: true,
|
|
620
|
+
entry: STOCK_ENTRY,
|
|
621
|
+
objects: [],
|
|
622
|
+
compatibilityFlags: [],
|
|
623
|
+
problems,
|
|
624
|
+
warnings: [...database.warnings],
|
|
625
|
+
database,
|
|
626
|
+
config,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
210
629
|
// a misspelled key (object for objects) was silently ignored, and the server went up without its Durable Objects
|
|
211
630
|
const extra = srv && typeof srv === 'object' ? Object.keys(srv).filter(k => !SERVER_KEYS.includes(k)) : [];
|
|
212
631
|
if (extra.length)
|
|
@@ -256,12 +675,17 @@ export async function readServerConfig(dir) {
|
|
|
256
675
|
problems.push(
|
|
257
676
|
`${CONFIG_FILE} server.compatibilityFlags may hold only ${ALLOWED_FLAGS.map(f => JSON.stringify(f)).join(', ')}`,
|
|
258
677
|
);
|
|
678
|
+
const database = await readDatabaseConfig(dir, config, { objects, own: true });
|
|
679
|
+
problems.push(...database.problems);
|
|
259
680
|
return {
|
|
260
681
|
has: true,
|
|
682
|
+
stock: false,
|
|
261
683
|
entry,
|
|
262
684
|
objects,
|
|
263
685
|
compatibilityFlags: Array.isArray(flags) ? [...new Set(flags.filter(f => ALLOWED_FLAGS.includes(f)))] : [],
|
|
264
686
|
problems,
|
|
687
|
+
warnings: [...database.warnings],
|
|
688
|
+
database,
|
|
265
689
|
config,
|
|
266
690
|
};
|
|
267
691
|
}
|