@xgamesfun/xcore 0.3.0 → 0.4.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/README.md +52 -16
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/docs/SERVER.md +90 -22
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +4 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +32 -9
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +485 -136
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +216 -1
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +91 -11
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +13 -4
package/lib/server/contract.mjs
CHANGED
|
@@ -21,8 +21,15 @@
|
|
|
21
21
|
// env.<binding> a Durable Object namespace for each declared object, SQLite storage
|
|
22
22
|
// env.GF_GAME_ID, env.GF_SLUG, env.GF_CONTRACT plain text
|
|
23
23
|
// env.<database.binding> the game's D1 database, when gamesfun.json has a database (env.DB)
|
|
24
|
-
// Runtime: Workers, compatibility date 2025-06-01; nodejs_compat
|
|
25
|
-
//
|
|
24
|
+
// Runtime: Workers, compatibility date 2025-06-01; no compatibility flags (nodejs_compat is not offered: the host
|
|
25
|
+
// refuses a publish that asks for it)
|
|
26
|
+
// Outbound: backend.json's origins (and xgames.fun's) are the list for the fetch() calls the entry makes while it
|
|
27
|
+
// answers a request (hosted; not enforced on this machine); nothing else is promised of it. No number of
|
|
28
|
+
// calls out a request is promised. A bundle that can open a raw socket is refused (SOCKET_MODULES,
|
|
29
|
+
// ./bundle.mjs).
|
|
30
|
+
// Off: an unpublished, taken-down or capped game's server is switched off: every request answers 451
|
|
31
|
+
// {error: 'stopped', reason}, alarms lapse and sockets close (a running room within about five minutes);
|
|
32
|
+
// its Durable Objects' data is kept, and it comes back when the game is published again or the cap lifts.
|
|
26
33
|
import { createHash } from 'node:crypto';
|
|
27
34
|
import { existsSync } from 'node:fs';
|
|
28
35
|
import { readdir, readFile } from 'node:fs/promises';
|
|
@@ -34,8 +41,37 @@ export const DEFAULT_ENTRY = 'server/index.js';
|
|
|
34
41
|
export const SERVER_DIR = 'server';
|
|
35
42
|
/** The runtime the hosted script runs on (xgames.fun's upload and its local parity use these). */
|
|
36
43
|
export const COMPATIBILITY_DATE = '2025-06-01';
|
|
37
|
-
/**
|
|
38
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The compatibility flags a game may ask for (gamesfun.json server.compatibilityFlags): none. nodejs_compat is not
|
|
46
|
+
* offered: with Node's built-in modules a server can open raw network connections in ways a check at publish cannot
|
|
47
|
+
* see, so xgames.fun refuses a publish that asks for it.
|
|
48
|
+
*/
|
|
49
|
+
export const ALLOWED_FLAGS = Object.freeze([]);
|
|
50
|
+
/** What a gamesfun.json that asks for nodejs_compat is told. */
|
|
51
|
+
export const NODE_COMPAT_REFUSED = `${CONFIG_FILE} server.compatibilityFlags asks for "nodejs_compat", which xgames.fun does not offer: with Node's built-in modules a server can open raw network connections, so a publish that asks for it is refused. Take it out ("compatibilityFlags": []) and use the Web APIs a Worker has (fetch, crypto.subtle, URL, TextEncoder, Response); a package that needs node:… does not run on xgames.fun`;
|
|
52
|
+
/**
|
|
53
|
+
* Modules a game's server may not import (statically, as a re-export or with import()), with or without `node:`: they
|
|
54
|
+
* open raw network connections (TCP, TLS, UDP, HTTP/2 over a socket) or load other modules by name. The host parses
|
|
55
|
+
* every module of the bundle and refuses one that imports them, and one with an import() of anything but a plain string
|
|
56
|
+
* (it could name any module at run time). A game's server reaches the network with fetch() only.
|
|
57
|
+
*/
|
|
58
|
+
export const SOCKET_MODULES = Object.freeze([
|
|
59
|
+
'cloudflare:sockets',
|
|
60
|
+
'node:net',
|
|
61
|
+
'node:tls',
|
|
62
|
+
'node:dgram',
|
|
63
|
+
'node:http2',
|
|
64
|
+
'node:module',
|
|
65
|
+
'node:child_process',
|
|
66
|
+
]);
|
|
67
|
+
const SOCKET_NAMES = new Set(SOCKET_MODULES.flatMap(m => (m.startsWith('node:') ? [m, m.slice(5)] : [m])));
|
|
68
|
+
/** The socket module an import names, as the runtime would resolve it (no query or fragment, any case); else null. */
|
|
69
|
+
export const socketModule = spec => {
|
|
70
|
+
const s = String(spec)
|
|
71
|
+
.replace(/[?#].*$/, '')
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
return SOCKET_NAMES.has(s) ? (s.includes(':') ? s : `node:${s}`) : null;
|
|
74
|
+
};
|
|
39
75
|
export const ROUTES = '/api/*';
|
|
40
76
|
/** The keys gamesfun.json → server takes. */
|
|
41
77
|
export const SERVER_KEYS = Object.freeze(['main', 'objects', 'routes', 'compatibilityFlags']);
|
|
@@ -57,8 +93,8 @@ export const reservedBinding = b => b === 'GF' || b.startsWith('GF_');
|
|
|
57
93
|
export const API_PREFIX = '/api/';
|
|
58
94
|
export const PLATFORM_PREFIX = '/platform/v1/';
|
|
59
95
|
/**
|
|
60
|
-
* Node built-ins nodejs_compat does not give a Worker at COMPATIBILITY_DATE (or gives only as a stub that throws)
|
|
61
|
-
*
|
|
96
|
+
* Node built-ins nodejs_compat does not give a Worker at COMPATIBILITY_DATE (or gives only as a stub that throws). Kept
|
|
97
|
+
* for code that reads it: no game's server gets Node's built-ins now (ALLOWED_FLAGS), so every node: import is refused.
|
|
62
98
|
*/
|
|
63
99
|
export const NODE_REFUSED = Object.freeze([
|
|
64
100
|
'child_process',
|
|
@@ -83,6 +119,9 @@ export const NODE_REFUSED = Object.freeze([
|
|
|
83
119
|
* env.GF, the platform binding (a Workers RPC service, entrypoint GameServices; locally ./gf-local.js). Every answer is
|
|
84
120
|
* {ok: true, …} or {ok: false, code, why}; a refusal is an answer, never a throw.
|
|
85
121
|
* verifyPlayer(token) → {ok, sub, wallet | null, x: {id, handle} | null, lvl, exp (unix s),
|
|
122
|
+
* (sub: the player's account, x:<X id>, or g:<id> for a Google account, an id
|
|
123
|
+
* different in every game; never the wallet, which is its own claim and may be
|
|
124
|
+
* null or change: data kept by wallet reads `wallet`)
|
|
86
125
|
* banned?: {kind: 'ban' | 'hide', until: ms | null} | null}
|
|
87
126
|
* (banned: the creator's ban on the player from the console; a token made
|
|
88
127
|
* before the ban still verifies, so a server refuses kind 'ban' itself)
|
|
@@ -96,7 +135,8 @@ export const NODE_REFUSED = Object.freeze([
|
|
|
96
135
|
* 'server': the page's /platform/v1/scores submit is refused, 403
|
|
97
136
|
* server-only, and what the page wrote before is removed; board may be a
|
|
98
137
|
* pattern, 'daily-*' or '*': the rule for every board it names, to come too)
|
|
99
|
-
* scores.drop(board) → {ok, dropped, removed} (the board gone: its place among the
|
|
138
|
+
* scores.drop(board) → {ok, dropped, removed} (the board gone: its place among the plan's
|
|
139
|
+
* boards free again: 8 on Free, 16 on Creator and Scale, PLAN_LIMITS)
|
|
100
140
|
* scores.top(board, {limit?, offset?}) → {ok, order, writer, entries: [{rank, player, name, score, at}]}
|
|
101
141
|
* scores.best(board, player) → {ok, entry: {rank, score, name, at} | null}
|
|
102
142
|
* scores.remove(board, player) → {ok, removed}
|
|
@@ -202,10 +242,49 @@ export const fileType = p => BY_EXTENSION[/\.([a-z0-9]+)$/i.exec(p)?.[1]?.toLowe
|
|
|
202
242
|
|
|
203
243
|
/** Server code that calls env.GF.files (its sources, comments left out), which needs "files" in server.features. */
|
|
204
244
|
export const usesFiles = code => /\bGF\s*\.\s*files\b|\bfiles\b[^;{}]*\}\s*=\s*[\w.]*\bGF\b/.test(code);
|
|
205
|
-
|
|
245
|
+
const MB = 1024 * 1024;
|
|
246
|
+
const frozen = o => Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Object.freeze(v)])));
|
|
247
|
+
/**
|
|
248
|
+
* The platform's numbers by the game's plan (xgames.fun's plan table; README "Its plan", `xcore plan` shows a published
|
|
249
|
+
* game's own): CPU a request, saves, scores, reads a minute, project files. A game is on Free unless it is in a paid
|
|
250
|
+
* slot (Creator) or granted Scale.
|
|
251
|
+
*/
|
|
252
|
+
export const PLAN_LIMITS = Object.freeze({
|
|
253
|
+
free: frozen({
|
|
254
|
+
cpuMs: 50,
|
|
255
|
+
saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 10 * MB, writesPerMinute: 600 },
|
|
256
|
+
scores: { boards: 8, rules: 16, entriesPerBoard: 10_000, submitsPerMinute: 600 },
|
|
257
|
+
reads: { perMinute: 1_200 },
|
|
258
|
+
files: { bytes: 100 * MB, fileBytes: 25 * MB },
|
|
259
|
+
}),
|
|
260
|
+
creator: frozen({
|
|
261
|
+
cpuMs: 200,
|
|
262
|
+
saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 1024 * MB, writesPerMinute: 12_000 },
|
|
263
|
+
scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 6_000 },
|
|
264
|
+
reads: { perMinute: 12_000 },
|
|
265
|
+
files: { bytes: 10 * 1024 * MB, fileBytes: 25 * MB },
|
|
266
|
+
}),
|
|
267
|
+
scale: frozen({
|
|
268
|
+
cpuMs: 1_000,
|
|
269
|
+
saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 10 * 1024 * MB, writesPerMinute: 60_000 },
|
|
270
|
+
scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 30_000 },
|
|
271
|
+
reads: { perMinute: 60_000 },
|
|
272
|
+
files: { bytes: 100 * 1024 * MB, fileBytes: 25 * MB },
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
/**
|
|
276
|
+
* The platform's per-game limits that a single number stands for: the same on every plan, and, where a plan sets it
|
|
277
|
+
* (boards, rules), Free's, the plan every game starts on (PLAN_LIMITS has the others).
|
|
278
|
+
*/
|
|
206
279
|
export const PLATFORM_LIMITS = Object.freeze({
|
|
207
280
|
saves: Object.freeze({ playerChars: 128, valueBytes: 64 * 1024, keysPerPlayer: 16 }),
|
|
208
|
-
scores: Object.freeze({
|
|
281
|
+
scores: Object.freeze({
|
|
282
|
+
boards: PLAN_LIMITS.free.scores.boards,
|
|
283
|
+
rules: PLAN_LIMITS.free.scores.rules,
|
|
284
|
+
nameChars: 32,
|
|
285
|
+
perPlayerEveryMs: 1000,
|
|
286
|
+
topMax: 100,
|
|
287
|
+
}),
|
|
209
288
|
loot: Object.freeze({ winners: 16 }),
|
|
210
289
|
});
|
|
211
290
|
/** A save key or a board name. */
|
|
@@ -674,9 +753,10 @@ export async function readServerConfig(dir) {
|
|
|
674
753
|
`${CONFIG_FILE} declares ${objects.length} Durable Object classes; a game's server may have ${LIMITS.objects}`,
|
|
675
754
|
);
|
|
676
755
|
const flags = srv.compatibilityFlags ?? [];
|
|
677
|
-
if (
|
|
756
|
+
if (Array.isArray(flags) && flags.includes('nodejs_compat')) problems.push(NODE_COMPAT_REFUSED);
|
|
757
|
+
else if (!Array.isArray(flags) || flags.some(f => !ALLOWED_FLAGS.includes(f)))
|
|
678
758
|
problems.push(
|
|
679
|
-
`${CONFIG_FILE} server.compatibilityFlags
|
|
759
|
+
`${CONFIG_FILE} server.compatibilityFlags must be an empty list: remove ${Array.isArray(flags) ? flags.map(f => JSON.stringify(f)).join(', ') : JSON.stringify(flags)} ("compatibilityFlags": []), since xgames.fun runs every game's server with none`,
|
|
680
760
|
);
|
|
681
761
|
const database = await readDatabaseConfig(dir, config, { objects, own: true });
|
|
682
762
|
problems.push(...database.problems);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgamesfun/xcore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "xgames.fun from the command line: bring a web game you already made, check it as xgames.fun hosts it, give it a backend and a Solana economy, and publish it at a link anyone can play.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"game",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"test": "node --test \"tests/**/*.test.mjs\""
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@xgamesfun/solana": "0.
|
|
59
|
+
"@xgamesfun/solana": "0.26.0",
|
|
60
60
|
"esbuild": "0.28.2",
|
|
61
61
|
"playwright-core": "1.63.0"
|
|
62
62
|
},
|
|
@@ -79,7 +79,9 @@ console errors, nothing missing, nothing from the root, something on screen), it
|
|
|
79
79
|
screenshots in `evidence/verify-N/` and look at them. A failing check 4 names the origin and why: declare it in
|
|
80
80
|
`backend.json` if the game really calls it, or remove the call. The page check does not say the game plays well:
|
|
81
81
|
that is the person's own game; ask them what to try, and try it with `xcore dev <folder>` (the game as xgames.fun
|
|
82
|
-
hosts it, with its server, sign-in, saves, rooms and economy RPC
|
|
82
|
+
hosts it, with its server, sign-in, saves, rooms and economy RPC, refusing what xgames.fun refuses on the Free plan;
|
|
83
|
+
`--plan creator` for a Creator game) or their own. A refusal there (`rate`, `full`, `too-many`, `order`) is one the
|
|
84
|
+
published game would get: fix the game, not the stand-in.
|
|
83
85
|
|
|
84
86
|
## 4. Players, a database, a backend (only when the game has or needs them)
|
|
85
87
|
|
|
@@ -111,7 +113,11 @@ supabase|railway` with the kit installed). Without the kit, they set one up them
|
|
|
111
113
|
([docs/SERVER.md](../../docs/SERVER.md)): a Worker on `/api/*`, Durable Objects for rooms, `env.GF` for sign-in, saves
|
|
112
114
|
and scores. Their page calls it with a relative address (`fetch('api/scores')`): `/api/*` at the published origin,
|
|
113
115
|
`/game/api/*` under `xcore dev`, which runs it under workerd with a local `env.GF`. A Node-only server stays on
|
|
114
|
-
their own host.
|
|
116
|
+
their own host, and so does anything that opens raw sockets (a database driver, a mail or chat client): xgames.fun
|
|
117
|
+
refuses `cloudflare:sockets`, `node:net` and the like, an `import()` of a name made at run time, and `nodejs_compat`.
|
|
118
|
+
Every route that does work checks the player's token first (`env.GF.verifyPlayer`; 401 at once without one): a
|
|
119
|
+
stranger's request that does work counts toward what caps the game. Key a player's data on `sub` (the account:
|
|
120
|
+
`x:…`, or `g:…` for Google, different in every game), never on the wallet; `wallet` is its own claim, maybe null.
|
|
115
121
|
|
|
116
122
|
## 5. An economy (only when they ask: tokens, NFTs, a shop, prizes, staking)
|
|
117
123
|
|
|
@@ -200,11 +206,14 @@ xcore publish <folder> # or gamesfun_login, then gamesfun_publish {"gam
|
|
|
200
206
|
```
|
|
201
207
|
|
|
202
208
|
It builds, runs the page check on exactly those files, uploads the published folder and the `server/` bundle. A failed
|
|
203
|
-
check makes a private draft only.
|
|
209
|
+
check makes a private draft only. `xcore login` asks the person to tick, on xgames.fun's approval page, the games this
|
|
210
|
+
computer may work on (or their whole account), and says which it got; a command refused with code `not-granted` means
|
|
211
|
+
the sign-in was not given that game: they run `xcore login` again and tick it (never work around it). Tell them the link, what you changed in their files, what the checks proved and what
|
|
204
212
|
nothing checked (their gameplay, their backend's own behaviour).
|
|
205
213
|
|
|
206
214
|
A game with a `server/`, saves or scores runs under its **plan** (`xcore plan <folder>`): Free by default (50 ms of CPU
|
|
207
|
-
and 600 requests a minute, $1 of use a month
|
|
215
|
+
and 600 requests a minute, $1 of use a month; past it the game is capped until the month ends: its server is switched
|
|
216
|
+
off and saves and scores slow down). When the game needs more
|
|
208
217
|
(a server that replays play, many players at once), tell them: a paid slot makes it Creator
|
|
209
218
|
(`xcore plan activate <folder>` once they have one), and the paid slot moves between their games at any time. Everyone
|
|
210
219
|
has 2 free slots: a third game is refused on publish (`no-free-slot`) until they archive one on their console
|