@xgamesfun/xcore 0.2.1 → 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 +139 -17
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/commands/loot.mjs +13 -4
- package/commands/publish.mjs +4 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +120 -32
- package/lib/add.mjs +1 -0
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +348 -9
- package/lib/client/godot/gamesfun.gd +10 -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/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +49 -10
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +490 -138
- package/lib/economy.mjs +12 -0
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/adopt.mjs +11 -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 +1166 -0
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +95 -12
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +58 -9
package/lib/dev/files.mjs
CHANGED
|
@@ -12,11 +12,84 @@ import { createReadStream, existsSync } from 'node:fs';
|
|
|
12
12
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
13
|
import { createServer } from 'node:http';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { FILES, filePathProblem,
|
|
15
|
+
import { FILES, filePathProblem, servedType } from '../server/contract.mjs';
|
|
16
16
|
import { stateFolder } from '../state-folder.mjs';
|
|
17
17
|
|
|
18
18
|
const no = (code, why) => ({ ok: false, code, why });
|
|
19
19
|
|
|
20
|
+
// ---- public files: the types a file may be public as, each known by its first bytes, as xgames.fun checks them ----
|
|
21
|
+
// A public file is media or data a page loads: an image, audio, video, a font, JSON or plain text, whose first bytes
|
|
22
|
+
// are plainly that type. Never a document a browser runs (HTML, SVG, XML, JavaScript, CSS, PDF) nor a download
|
|
23
|
+
// (programs, archives, application/octet-stream): those stay private, and the game's own server serves them.
|
|
24
|
+
const at = (b, offset, sig) => b.length >= offset + sig.length && sig.every((x, i) => b[offset + i] === x);
|
|
25
|
+
const word = (b, offset, text) =>
|
|
26
|
+
at(
|
|
27
|
+
b,
|
|
28
|
+
offset,
|
|
29
|
+
[...text].map(c => c.charCodeAt(0)),
|
|
30
|
+
);
|
|
31
|
+
/** The first byte that is not whitespace (after a UTF-8 byte order mark), or -1. */
|
|
32
|
+
function firstVisible(b) {
|
|
33
|
+
let i = at(b, 0, [0xef, 0xbb, 0xbf]) ? 3 : 0;
|
|
34
|
+
while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i++;
|
|
35
|
+
return i < b.length ? b[i] : -1;
|
|
36
|
+
}
|
|
37
|
+
const riff = (b, kind) => word(b, 0, 'RIFF') && word(b, 8, kind);
|
|
38
|
+
const ftyp = (b, brands = null) => word(b, 4, 'ftyp') && (!brands || brands.some(x => word(b, 8, x)));
|
|
39
|
+
const mpegAudio = b => word(b, 0, 'ID3') || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0);
|
|
40
|
+
const ogg = b => word(b, 0, 'OggS'),
|
|
41
|
+
webm = b => at(b, 0, [0x1a, 0x45, 0xdf, 0xa3]),
|
|
42
|
+
wave = b => riff(b, 'WAVE');
|
|
43
|
+
export const PUBLIC_TYPES = Object.freeze({
|
|
44
|
+
'image/png': b => at(b, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
45
|
+
'image/jpeg': b => at(b, 0, [0xff, 0xd8, 0xff]),
|
|
46
|
+
'image/gif': b => word(b, 0, 'GIF87a') || word(b, 0, 'GIF89a'),
|
|
47
|
+
'image/webp': b => riff(b, 'WEBP'),
|
|
48
|
+
'image/avif': b => ftyp(b, ['avif', 'avis']),
|
|
49
|
+
'audio/mpeg': mpegAudio,
|
|
50
|
+
'audio/ogg': ogg,
|
|
51
|
+
'video/ogg': ogg,
|
|
52
|
+
'audio/wav': wave,
|
|
53
|
+
'audio/wave': wave,
|
|
54
|
+
'audio/x-wav': wave,
|
|
55
|
+
'audio/webm': webm,
|
|
56
|
+
'video/webm': webm,
|
|
57
|
+
'audio/mp4': b => ftyp(b),
|
|
58
|
+
'audio/x-m4a': b => ftyp(b),
|
|
59
|
+
'video/mp4': b => ftyp(b),
|
|
60
|
+
'audio/aac': b => b[0] === 0xff && (b[1] & 0xf6) === 0xf0,
|
|
61
|
+
'audio/flac': b => word(b, 0, 'fLaC'),
|
|
62
|
+
'font/woff': b => word(b, 0, 'wOFF'),
|
|
63
|
+
'font/woff2': b => word(b, 0, 'wOF2'),
|
|
64
|
+
'font/ttf': b => at(b, 0, [0, 1, 0, 0]) || word(b, 0, 'true'),
|
|
65
|
+
'font/otf': b => word(b, 0, 'OTTO'),
|
|
66
|
+
'font/collection': b => word(b, 0, 'ttcf'),
|
|
67
|
+
'application/json': b => {
|
|
68
|
+
const c = firstVisible(b);
|
|
69
|
+
return c >= 0 && '{["-0123456789tfn'.includes(String.fromCharCode(c));
|
|
70
|
+
},
|
|
71
|
+
'text/plain': b => !b.includes(0) && firstVisible(b) !== 0x3c,
|
|
72
|
+
});
|
|
73
|
+
/** How many leading bytes the public check reads. */
|
|
74
|
+
const SNIFF_BYTES = 512;
|
|
75
|
+
const PUBLIC_WORDS =
|
|
76
|
+
'an image (PNG, JPEG, WebP, GIF, AVIF), audio (MP3, Ogg, WAV, WebM, MP4, AAC, FLAC), video (MP4, WebM, Ogg), a font (WOFF, WOFF2, TTF, OTF), JSON or plain text';
|
|
77
|
+
/** A type's essence: "Image/PNG; x=y" → "image/png". */
|
|
78
|
+
const essenceOf = type =>
|
|
79
|
+
String(type ?? '')
|
|
80
|
+
.split(';')[0]
|
|
81
|
+
.trim()
|
|
82
|
+
.toLowerCase();
|
|
83
|
+
const MEDIA_TYPE =
|
|
84
|
+
/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(\s*;\s*[a-z0-9_-]+=("[^"\\]*"|[a-z0-9_.+-]+))*$/i;
|
|
85
|
+
/** The type a put names (lower case), application/octet-stream for none, or null when it is not a media type. */
|
|
86
|
+
const typeOf = type =>
|
|
87
|
+
type == null || type === ''
|
|
88
|
+
? 'application/octet-stream'
|
|
89
|
+
: typeof type === 'string' && type.length <= 128 && MEDIA_TYPE.test(type)
|
|
90
|
+
? type.trim().toLowerCase()
|
|
91
|
+
: null;
|
|
92
|
+
|
|
20
93
|
/** One game's files. `origin`: where the public ones are served (a function: the files server's port comes later). */
|
|
21
94
|
export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes, fileBytes = FILES.fileBytes }) {
|
|
22
95
|
const root = path.join(dir, '.gamesfun', 'dev', 'files');
|
|
@@ -56,7 +129,10 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
|
|
|
56
129
|
|
|
57
130
|
return {
|
|
58
131
|
root,
|
|
59
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* body: a string, bytes (ArrayBuffer, a typed array, a Buffer). `type`: a media type (none: application/octet-stream);
|
|
134
|
+
* `public`: served on the files origin, for the public types only (PUBLIC_TYPES). → {ok, path, bytes, url?}
|
|
135
|
+
*/
|
|
60
136
|
put(p, body, { type, public: isPublic = false } = {}) {
|
|
61
137
|
return queued(async () => {
|
|
62
138
|
const bad = filePathProblem(p);
|
|
@@ -70,6 +146,20 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
|
|
|
70
146
|
? Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
|
71
147
|
: null;
|
|
72
148
|
if (!bytes) return no('body', 'A file is a string, an ArrayBuffer or a stream of bytes.');
|
|
149
|
+
const t = typeOf(type);
|
|
150
|
+
if (t === null) return no('type', 'A file’s type is a media type, like image/png or application/json.');
|
|
151
|
+
// a public file is media or data the game's page loads, plainly so by its first bytes
|
|
152
|
+
const shown = isPublic === true;
|
|
153
|
+
if (shown && !PUBLIC_TYPES[essenceOf(t)])
|
|
154
|
+
return no(
|
|
155
|
+
'type',
|
|
156
|
+
`A public file is ${PUBLIC_WORDS}; this one is ${essenceOf(t)}. Keep it private (public: false) and serve it from the game’s server.`,
|
|
157
|
+
);
|
|
158
|
+
if (shown && !PUBLIC_TYPES[essenceOf(t)](bytes.subarray(0, SNIFF_BYTES)))
|
|
159
|
+
return no(
|
|
160
|
+
'type',
|
|
161
|
+
`This file’s first bytes are not ${essenceOf(t)}, so it can’t be public as that. A public file is ${PUBLIC_WORDS}.`,
|
|
162
|
+
);
|
|
73
163
|
if (bytes.length > fileBytes)
|
|
74
164
|
return no(
|
|
75
165
|
'too-big',
|
|
@@ -88,8 +178,10 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
|
|
|
88
178
|
await rename(`${file}.gf-tmp`, file);
|
|
89
179
|
m[p] = {
|
|
90
180
|
bytes: bytes.length,
|
|
91
|
-
type:
|
|
92
|
-
public:
|
|
181
|
+
type: t,
|
|
182
|
+
public: shown,
|
|
183
|
+
// the type its first bytes showed: only a file so marked is served as itself
|
|
184
|
+
...(shown ? { checked: essenceOf(t) } : {}),
|
|
93
185
|
updatedAt: Date.now(),
|
|
94
186
|
etag: createHash('sha256').update(bytes).digest('hex').slice(0, 32),
|
|
95
187
|
};
|
|
@@ -206,7 +298,12 @@ export function serveFiles(store, { port = 0, host = '127.0.0.1' } = {}) {
|
|
|
206
298
|
if (filePathProblem(p)) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
|
|
207
299
|
const f = await store.served(p);
|
|
208
300
|
if (!f) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
|
|
209
|
-
|
|
301
|
+
// as itself only when its first bytes were checked as its type; anything else (a file kept before the check) is a
|
|
302
|
+
// download
|
|
303
|
+
const { type, inline } =
|
|
304
|
+
f.meta.checked && f.meta.checked === essenceOf(f.meta.type)
|
|
305
|
+
? servedType(f.meta.type)
|
|
306
|
+
: { type: 'application/octet-stream', inline: false };
|
|
210
307
|
const etag = `"${f.meta.etag}"`;
|
|
211
308
|
const head = {
|
|
212
309
|
'content-type': type,
|
package/lib/dev/gf-local.js
CHANGED
|
@@ -67,6 +67,10 @@ class Scores extends RpcTarget {
|
|
|
67
67
|
drop(board) {
|
|
68
68
|
return call(this.#env, 'scores.drop', [board]);
|
|
69
69
|
}
|
|
70
|
+
/** The game's boards (who made each: 'server' or 'page') and rules, as xgames.fun's GF.scores.list answers. */
|
|
71
|
+
list() {
|
|
72
|
+
return call(this.#env, 'scores.list', []);
|
|
73
|
+
}
|
|
70
74
|
}
|
|
71
75
|
class Loot extends RpcTarget {
|
|
72
76
|
#env;
|
package/lib/dev/index.mjs
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// /game/* the published files (gamesfun.json page.out), with the play host's headers (../check.mjs servePage)
|
|
4
4
|
// /game/api/* the game's own server/ under workerd (./server.mjs), WebSockets too, sent on as /api/*; for a
|
|
5
5
|
// game with a database and no server/, xcore's stock server, with a local D1 (./d1.mjs)
|
|
6
|
-
// /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well
|
|
6
|
+
// /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well, held to the
|
|
7
|
+
// limits of the plan it imitates (./plans.mjs: Free unless `plan` says another)
|
|
8
|
+
// /platform/v1/live the game's announcement, config and maintenance, from .gamesfun/live.json (./live.mjs); while
|
|
9
|
+
// maintenance is on there, the holding page for its HTML and 503 'maintenance' for its api/
|
|
10
|
+
// and platform calls, as xgames.fun's play edge does
|
|
7
11
|
// <files origin>/* the game's public project files (env.GF.files, xcore files), on a port of its own: another
|
|
8
12
|
// origin to the browser (cross-origin, though same-site: both are 127.0.0.1), as
|
|
9
13
|
// <slug>.xgamesusercontent.com is live, where it is another site too (./files.mjs)
|
|
@@ -20,7 +24,9 @@ import { ownOrigins } from '../publish/client.mjs';
|
|
|
20
24
|
import { readServerConfig, API_PREFIX } from '../server/contract.mjs';
|
|
21
25
|
import { PAGE_RPC, rpcWords, solanaRpc } from '../solana-rpc.mjs';
|
|
22
26
|
import { handlePlatform, createPlatformStore } from './platform.mjs';
|
|
27
|
+
import { createLiveRoutes, readLive, LIVE_FILE } from './live.mjs';
|
|
23
28
|
import { createLimitWatch } from './limits.mjs';
|
|
29
|
+
import { devPlan, planLine } from './plans.mjs';
|
|
24
30
|
import { serveFiles } from './files.mjs';
|
|
25
31
|
|
|
26
32
|
const readJson = file => {
|
|
@@ -102,20 +108,27 @@ async function body(req, cap = 256 * 1024) {
|
|
|
102
108
|
return Buffer.concat(chunks);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
/** What xcore dev does not imitate, said once when it starts. */
|
|
112
|
+
export const NOT_IMITATED =
|
|
113
|
+
"platform: not imitated, only said in this log when a page passes them: one player's requests to the game's " +
|
|
114
|
+
"server a minute and its CPU a request, and one address's reads of /platform/v1; not imitated at all: the " +
|
|
115
|
+
"network's delays and the owner's moderation (bans, blocked words); a board's player ids are this machine's own";
|
|
116
|
+
|
|
111
117
|
/** A Host header naming this machine: localhost, 127.0.0.1, [::1] or a name under .localhost, with any port. */
|
|
112
118
|
export const isLocalHost = host =>
|
|
113
119
|
/^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)(:\d{1,5})?$/i.test(String(host ?? ''));
|
|
114
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Start the dev host for `dir`. Options: port (default 8787; 0 any), filesPort (default the next one; 0 any), noBuild,
|
|
123
|
+
* plan (the plan imitated: 'free', the default, 'creator' or 'scale'), log, env, fetchImpl (the RPC forward's).
|
|
124
|
+
* → {url, origin, files (the public files' origin), rpc, plan, server (the game server's status, or null), store,
|
|
125
|
+
* reload(), close()}
|
|
126
|
+
*/
|
|
115
127
|
export async function startDev(
|
|
116
128
|
dir,
|
|
117
129
|
{
|
|
118
130
|
port = 8787,
|
|
131
|
+
plan: planName = 'free',
|
|
119
132
|
filesPort = port ? port + 1 : 0,
|
|
120
133
|
noBuild = false,
|
|
121
134
|
log = m => console.log(m),
|
|
@@ -124,6 +137,7 @@ export async function startDev(
|
|
|
124
137
|
} = {},
|
|
125
138
|
) {
|
|
126
139
|
const game = path.resolve(dir);
|
|
140
|
+
const plan = devPlan(planName);
|
|
127
141
|
const roots = pageRoots(game);
|
|
128
142
|
if (roots.build && !noBuild) {
|
|
129
143
|
log(`build: ${roots.build}`);
|
|
@@ -138,7 +152,9 @@ export async function startDev(
|
|
|
138
152
|
`no index.html in ${path.relative(game, roots.out) || '.'}: ${roots.build ? 'build the game first' : "export the game for the Web there first (or adopt it again with --out <the export's folder>)"}`,
|
|
139
153
|
);
|
|
140
154
|
|
|
141
|
-
const store = createPlatformStore({ dir: game, log: m => log(`platform: ${m}`) });
|
|
155
|
+
const store = createPlatformStore({ dir: game, plan, log: m => log(`platform: ${m}`) });
|
|
156
|
+
log(planLine(plan));
|
|
157
|
+
log(NOT_IMITATED);
|
|
142
158
|
// the game's public files on an origin of their own, as <slug>.xgamesusercontent.com is to the published game:
|
|
143
159
|
// another port is another origin (no sign-in, no calls to the game's api/), though the same site here (both are
|
|
144
160
|
// 127.0.0.1); live, xgamesusercontent.com is another site as well
|
|
@@ -215,8 +231,16 @@ export async function startDev(
|
|
|
215
231
|
res.end(text);
|
|
216
232
|
}
|
|
217
233
|
|
|
218
|
-
const limits = createLimitWatch({ log });
|
|
234
|
+
const limits = createLimitWatch({ log, plan });
|
|
219
235
|
const apiAt = `/game${API_PREFIX}`; // the page's api/ beside it: /game/api/
|
|
236
|
+
const live = createLiveRoutes({ dir: game, apiAt, say: problems => log(`live: ${problems.join('; ')}`) });
|
|
237
|
+
const said = await readLive(game);
|
|
238
|
+
if (said.exists) {
|
|
239
|
+
const l = said.live;
|
|
240
|
+
log(
|
|
241
|
+
`live: ${LIVE_FILE}${l.maintenance ? ' (maintenance on: the game shows its holding page)' : ''}${l.announcement ? ', an announcement' : ''}${l.version ? `, config v${l.version}` : ''}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
220
244
|
const route = async (req, res, url) => {
|
|
221
245
|
// only this machine's own names: a page on another site that points a name of its own at 127.0.0.1 (DNS rebinding)
|
|
222
246
|
// must not reach the platform, its sign-in tokens or the game's server here
|
|
@@ -226,6 +250,17 @@ export async function startDev(
|
|
|
226
250
|
return true;
|
|
227
251
|
}
|
|
228
252
|
if (url.pathname === PAGE_RPC) return (await forwardRpc(req, res), true);
|
|
253
|
+
if (await live.handle(req, res, url)) return true;
|
|
254
|
+
// the play edge holds one address's reads of /platform/v1 to a number a minute; a board read with no
|
|
255
|
+
// Authorization header may come from its cache, which is not counted
|
|
256
|
+
const p = url.pathname;
|
|
257
|
+
if (
|
|
258
|
+
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
259
|
+
p.startsWith('/platform/v1/') &&
|
|
260
|
+
!p.startsWith('/platform/v1/dev/') &&
|
|
261
|
+
!(/^\/platform\/v1\/scores\/[^/]+$/.test(p) && !req.headers.authorization)
|
|
262
|
+
)
|
|
263
|
+
limits.platformRead(`${req.method} ${p}`);
|
|
229
264
|
if (await handlePlatform(req, res, url, store)) return true;
|
|
230
265
|
if (url.pathname.startsWith(apiAt) || url.pathname === apiAt.slice(0, -1)) {
|
|
231
266
|
const rest = url.pathname.slice('/game'.length) + url.search;
|
|
@@ -259,9 +294,12 @@ export async function startDev(
|
|
|
259
294
|
await files.close();
|
|
260
295
|
throw e;
|
|
261
296
|
}
|
|
262
|
-
site.server.on('upgrade', (req, socket, head) => {
|
|
297
|
+
site.server.on('upgrade', async (req, socket, head) => {
|
|
263
298
|
if (!isLocalHost(req.headers.host)) return socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
264
299
|
const u = new URL(req.url, 'http://x');
|
|
300
|
+
// paused (.gamesfun/live.json maintenance): refused, as xgames.fun refuses the game's api/ then
|
|
301
|
+
if (await live.refuseUpgrade(u.pathname))
|
|
302
|
+
return socket.end('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 60\r\nConnection: close\r\n\r\n');
|
|
265
303
|
if (server && u.pathname.startsWith(apiAt)) {
|
|
266
304
|
limits.upgrade(u.pathname.slice('/game'.length));
|
|
267
305
|
server.upgrade(req, socket, head, u.pathname.slice('/game'.length) + u.search);
|
|
@@ -338,6 +376,7 @@ export async function startDev(
|
|
|
338
376
|
origin: site.origin,
|
|
339
377
|
files: files.origin,
|
|
340
378
|
rpc: rpcWords(rpc),
|
|
379
|
+
plan: plan.key,
|
|
341
380
|
server: server ? server.status() : null,
|
|
342
381
|
store,
|
|
343
382
|
reload: () => server?.reload(),
|
package/lib/dev/limits.mjs
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
|
-
// What `xcore dev` says about xgames.fun's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
1
|
+
// What `xcore dev` says about xgames.fun's limits that depend on the network, not on the game's store: one player's
|
|
2
|
+
// requests to the game's server a minute and the CPU each may take (by plan), and one address's reads of the page's
|
|
3
|
+
// /platform/v1 at the play edge. The local server runs with no CPU or request limits, and here every tab is the same
|
|
4
|
+
// address, so these are said in the dev log, never refused: once a minute at most for a rate, once per route for the
|
|
5
|
+
// time. The platform's own limits (saves, scores, reads and writes a minute) are refused as published: ./platform.mjs.
|
|
5
6
|
//
|
|
6
|
-
// The numbers are
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// The numbers are ./plans.mjs's, one table with the rest.
|
|
8
|
+
import { PLANS, PLAN_NAMES, RULES, devPlan } from './plans.mjs';
|
|
9
|
+
|
|
10
|
+
/** The plans' request numbers, as this watch reads them (kept for callers of an earlier version). */
|
|
11
|
+
export const PLAN_LIMITS = Object.freeze(
|
|
12
|
+
Object.fromEntries(
|
|
13
|
+
PLAN_NAMES.map(k => [
|
|
14
|
+
k,
|
|
15
|
+
Object.freeze({ name: PLANS[k].name, perPlayer: PLANS[k].rate.player, cpuMs: PLANS[k].cpuMs }),
|
|
16
|
+
]),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
11
19
|
const MINUTE = 60_000;
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
|
-
* A watch over one dev session's server requests. `log(line)` takes what it has to say.
|
|
15
|
-
* → {request(method, pathname) → done(), upgrade(pathname)}: request() before the server is
|
|
16
|
-
* has answered (its headers).
|
|
22
|
+
* A watch over one dev session's server requests. `log(line)` takes what it has to say; `plan`: the plan imitated.
|
|
23
|
+
* → {request(method, pathname) → done(), upgrade(pathname), platformRead(what)}: request() before the server is
|
|
24
|
+
* called, done() when it has answered (its headers).
|
|
17
25
|
*/
|
|
18
|
-
export function createLimitWatch({ log, now = () => Date.now() }) {
|
|
19
|
-
const
|
|
26
|
+
export function createLimitWatch({ log, now = () => Date.now(), plan = 'free' }) {
|
|
27
|
+
const key = typeof plan === 'object' && plan?.key ? plan.key : devPlan(plan).key;
|
|
28
|
+
const here = PLAN_LIMITS[key];
|
|
29
|
+
const up = PLAN_LIMITS[PLAN_NAMES[PLAN_NAMES.indexOf(key) + 1]] ?? null;
|
|
20
30
|
let start = now();
|
|
21
31
|
let count = 0;
|
|
22
|
-
let said = 0; //
|
|
32
|
+
let said = 0; // what is already said this minute: 0 nothing, 1 past this plan's, 2 past the next plan's too
|
|
23
33
|
const slow = new Set();
|
|
34
|
+
let readStart = now(),
|
|
35
|
+
reads = 0,
|
|
36
|
+
readSaid = false;
|
|
24
37
|
|
|
25
38
|
function count1(what) {
|
|
26
39
|
const t = now();
|
|
@@ -30,21 +43,21 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
|
|
|
30
43
|
said = 0;
|
|
31
44
|
}
|
|
32
45
|
count++;
|
|
33
|
-
if (count >
|
|
46
|
+
if (up && count > up.perPlayer && said < 2) {
|
|
34
47
|
said = 2;
|
|
35
48
|
log(
|
|
36
49
|
`limits: ${count} requests to the game's server within a minute (the last: ${what}). Published, one player's ` +
|
|
37
|
-
`requests past ${
|
|
50
|
+
`requests past ${up.perPlayer} a minute are refused even on ${up.name} (${here.perPlayer} on ${here.name}): its ` +
|
|
38
51
|
`server answers 429 with retry-after 60. Send frequent updates (positions, state) over a WebSocket ` +
|
|
39
52
|
`(docs/SERVER.md, "A room over a WebSocket"); a socket is one request for its whole life.`,
|
|
40
53
|
);
|
|
41
|
-
} else if (count >
|
|
54
|
+
} else if (count > here.perPlayer && said < 1) {
|
|
42
55
|
said = 1;
|
|
43
56
|
log(
|
|
44
57
|
`limits: ${count} requests to the game's server within a minute (the last: ${what}). Published on ` +
|
|
45
|
-
`${
|
|
46
|
-
`${
|
|
47
|
-
`(docs/SERVER.md). Two tabs here count as one player.`,
|
|
58
|
+
`${here.name}, one player's requests past ${here.perPlayer} a minute are refused (429, retry-after 60)` +
|
|
59
|
+
`${up ? `; ${up.name} allows ${up.perPlayer}` : ''}. Poll less often, or send frequent updates over a ` +
|
|
60
|
+
`WebSocket (docs/SERVER.md). Two tabs here count as one player.`,
|
|
48
61
|
);
|
|
49
62
|
}
|
|
50
63
|
}
|
|
@@ -56,11 +69,11 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
|
|
|
56
69
|
const t0 = now();
|
|
57
70
|
return () => {
|
|
58
71
|
const ms = now() - t0;
|
|
59
|
-
if (ms <=
|
|
72
|
+
if (ms <= here.cpuMs || slow.has(what)) return;
|
|
60
73
|
slow.add(what);
|
|
61
74
|
log(
|
|
62
|
-
`limits: ${what} took ${ms} ms here. Published, a request gets ${
|
|
63
|
-
`(${
|
|
75
|
+
`limits: ${what} took ${ms} ms here. Published, a request gets ${here.cpuMs} ms of CPU on ${here.name}` +
|
|
76
|
+
`${up ? ` (${up.cpuMs} on ${up.name})` : ''}. Waiting on storage or fetch does not count, but if this is ` +
|
|
64
77
|
`computing, it can pass a few times live and then answer 503 ("went over its limit"). Do less a ` +
|
|
65
78
|
`request, or split the work.`,
|
|
66
79
|
);
|
|
@@ -69,5 +82,23 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
|
|
|
69
82
|
upgrade(pathname) {
|
|
70
83
|
count1(`WebSocket ${pathname}`);
|
|
71
84
|
},
|
|
85
|
+
/** A read of /platform/v1 that the play edge would count against the address it came from. */
|
|
86
|
+
platformRead(what) {
|
|
87
|
+
const t = now(),
|
|
88
|
+
max = RULES.edge.platformReadsPerCallerMinute;
|
|
89
|
+
if (t - readStart >= MINUTE) {
|
|
90
|
+
readStart = t;
|
|
91
|
+
reads = 0;
|
|
92
|
+
readSaid = false;
|
|
93
|
+
}
|
|
94
|
+
if (++reads <= max || readSaid) return;
|
|
95
|
+
readSaid = true;
|
|
96
|
+
log(
|
|
97
|
+
`limits: ${reads} reads of /platform/v1 within a minute (the last: ${what}). Published, the play edge ` +
|
|
98
|
+
`refuses one address's reads past ${max} a minute (429 'rate'), on every plan; a board read with no ` +
|
|
99
|
+
`token may come from its 5 s cache and is not counted. Here every tab shares one address, so this is only ` +
|
|
100
|
+
`said: read less often (a board after a score, not on a timer).`,
|
|
101
|
+
);
|
|
102
|
+
},
|
|
72
103
|
};
|
|
73
104
|
}
|
package/lib/dev/live.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The game's live operations on this machine, for `xcore dev`. Hosted, the game's owner sets an announcement, a remote
|
|
2
|
+
// config and maintenance mode on xgames.fun/console (the Live tab), and the play edge answers GET /platform/v1/live
|
|
3
|
+
// itself (gamesfun.js's gf.live). Here they come from a file the creator writes, read again on every request:
|
|
4
|
+
//
|
|
5
|
+
// <game>/.gamesfun/live.json (a hidden folder: never published)
|
|
6
|
+
// {
|
|
7
|
+
// "announcement": {"text": "Double XP all weekend!", "level": "info", "startsAt": "2026-11-09T18:00:00Z", "endsAt": null},
|
|
8
|
+
// "config": {"difficulty": "hard", "event": true},
|
|
9
|
+
// "maintenance": {"message": "Back at 7pm"} // or true, or null / left out: not paused
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// The rules are the hosted ones (xgames.fun's play edge, parseLive): the announcement only between its start and end,
|
|
13
|
+
// plain text cut to 280 characters, info or warning; the config a JSON object of at most 8,192 bytes (a bigger one, or
|
|
14
|
+
// one that is not an object, is left out here and said in the log: xgames.fun refuses it). While maintenance is on, the
|
|
15
|
+
// dev host serves the holding page for the game's HTML pages and answers 503 {ok: false, code: 'maintenance'} to its
|
|
16
|
+
// /game/api/* and /platform/v1/* (but /platform/v1/live and the local sign-in), as xgames.fun does.
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
// Shown to people as written (the same on Windows); path.join(dir, LIVE_FILE) still reads it there.
|
|
22
|
+
export const LIVE_FILE = '.gamesfun/live.json';
|
|
23
|
+
export const LIVE_TEXT_CHARS = 280;
|
|
24
|
+
export const LIVE_CONFIG_BYTES = 8192;
|
|
25
|
+
export const MAINTENANCE_DEFAULT = 'This game is paused for maintenance. It will be back soon.';
|
|
26
|
+
export const LIVE_EMPTY = Object.freeze({ announcement: null, config: {}, version: 0, maintenance: null });
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line no-control-regex -- control characters never reach a player's screen (tabs and new lines may)
|
|
29
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
|
|
30
|
+
const text = (v, max) => (typeof v === 'string' ? v.replace(CONTROL, '').trim().slice(0, max) : '');
|
|
31
|
+
const time = v => {
|
|
32
|
+
const t = typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
|
|
33
|
+
return Number.isFinite(t) && t > 0 ? t : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The file's value → {live: {announcement, config, version, maintenance} as players would get it at `now`, problems:
|
|
38
|
+
* [words]}. Times may be ms or ISO text here (the console sends ms).
|
|
39
|
+
*/
|
|
40
|
+
export function parseLiveFile(value, now = Date.now()) {
|
|
41
|
+
const problems = [];
|
|
42
|
+
if (value === null || value === undefined) return { live: LIVE_EMPTY, problems };
|
|
43
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
44
|
+
return { live: LIVE_EMPTY, problems: [`${LIVE_FILE} is a JSON object: {announcement, config, maintenance}.`] };
|
|
45
|
+
let announcement = null;
|
|
46
|
+
const a = value.announcement;
|
|
47
|
+
if (a && typeof a === 'object' && text(a.text, LIVE_TEXT_CHARS)) {
|
|
48
|
+
const startsAt = time(a.startsAt),
|
|
49
|
+
endsAt = time(a.endsAt);
|
|
50
|
+
if ([...a.text].length > LIVE_TEXT_CHARS)
|
|
51
|
+
problems.push(`the announcement is cut to ${LIVE_TEXT_CHARS} characters (xgames.fun refuses a longer one)`);
|
|
52
|
+
if (a.level !== undefined && a.level !== 'info' && a.level !== 'warning')
|
|
53
|
+
problems.push(`an announcement's level is info or warning, not ${JSON.stringify(a.level)}`);
|
|
54
|
+
if ((startsAt === null || startsAt <= now) && (endsAt === null || endsAt > now))
|
|
55
|
+
announcement = {
|
|
56
|
+
id: createHash('sha256').update(`${a.text}\n${startsAt}\n${endsAt}`).digest('hex').slice(0, 12),
|
|
57
|
+
text: text(a.text, LIVE_TEXT_CHARS),
|
|
58
|
+
level: a.level === 'warning' ? 'warning' : 'info',
|
|
59
|
+
startsAt,
|
|
60
|
+
endsAt,
|
|
61
|
+
};
|
|
62
|
+
} else if (a) problems.push('the announcement needs its text: {"text": "…"}');
|
|
63
|
+
let config = {};
|
|
64
|
+
const c = value.config;
|
|
65
|
+
if (c !== undefined && c !== null) {
|
|
66
|
+
const bytes = Buffer.byteLength(JSON.stringify(c));
|
|
67
|
+
if (typeof c !== 'object' || Array.isArray(c)) problems.push('the config is a JSON object: {"difficulty": "hard"}');
|
|
68
|
+
else if (bytes > LIVE_CONFIG_BYTES)
|
|
69
|
+
problems.push(`the config is ${bytes} bytes as JSON; xgames.fun takes ${LIVE_CONFIG_BYTES} at most`);
|
|
70
|
+
else config = c;
|
|
71
|
+
}
|
|
72
|
+
const m = value.maintenance;
|
|
73
|
+
const maintenance =
|
|
74
|
+
m === true || (m && typeof m === 'object')
|
|
75
|
+
? { message: text(m?.message, LIVE_TEXT_CHARS) || MAINTENANCE_DEFAULT, since: time(m?.since) ?? null }
|
|
76
|
+
: null;
|
|
77
|
+
const version = Object.keys(config).length ? (Number.isSafeInteger(value.version) ? value.version : 1) : 0;
|
|
78
|
+
return { live: { announcement, config, version, maintenance }, problems };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Reads <game>/.gamesfun/live.json now. → {live, problems, exists}; never throws (a broken file is said, not fatal). */
|
|
82
|
+
export async function readLive(dir, now = Date.now()) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = await readFile(path.join(dir, LIVE_FILE), 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
return { live: LIVE_EMPTY, problems: [], exists: false };
|
|
88
|
+
}
|
|
89
|
+
let value;
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(raw);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { live: LIVE_EMPTY, problems: [`${LIVE_FILE} is not JSON: ${e.message}`], exists: true };
|
|
94
|
+
}
|
|
95
|
+
return { ...parseLiveFile(value, now), exists: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The holding page xgames.fun's play edge serves for the game's HTML while it is paused (503, never cached). */
|
|
99
|
+
export function maintenancePage(maintenance) {
|
|
100
|
+
const esc = s =>
|
|
101
|
+
s.replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
102
|
+
return (
|
|
103
|
+
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="60"><title>Back soon</title>' +
|
|
104
|
+
'<body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0e;color:#d0d0d8;font:16px/1.5 system-ui,sans-serif">' +
|
|
105
|
+
`<main style="max-width:32rem;padding:24px;text-align:center"><p style="margin:0 0 8px;font-weight:600;color:#fff">Paused for maintenance</p><p style="margin:0">${esc(maintenance.message)}</p>` +
|
|
106
|
+
`<p style="margin:16px 0 0;font-size:13px;opacity:.6">xcore dev: ${LIVE_FILE} has maintenance on.</p></main></body>`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
/** The refusal of /api/* and /platform/v1/* while paused, as xgames.fun's play edge answers it. */
|
|
110
|
+
export const maintenanceBody = m => ({ ok: false, code: 'maintenance', why: m.message, since: m.since });
|
|
111
|
+
|
|
112
|
+
const LIVE_PATH = '/platform/v1/live';
|
|
113
|
+
/**
|
|
114
|
+
* The dev host's live routes (./index.mjs route, tried before the platform's): GET /platform/v1/live from the file, and
|
|
115
|
+
* while maintenance is on, the holding page for the game's HTML under /game/ and 503 'maintenance' for /game/api/* and
|
|
116
|
+
* /platform/v1/* (not /platform/v1/live, nor the local sign-in /platform/v1/dev/*). → true when it answered.
|
|
117
|
+
* `say(problems)`: the file's problems, each time they change.
|
|
118
|
+
*/
|
|
119
|
+
export function createLiveRoutes({ dir, apiAt = '/game/api/', say = () => {}, now = () => Date.now() }) {
|
|
120
|
+
let said = '';
|
|
121
|
+
const current = async () => {
|
|
122
|
+
const r = await readLive(dir, now());
|
|
123
|
+
const words = r.problems.join('; ');
|
|
124
|
+
if (words !== said) {
|
|
125
|
+
said = words;
|
|
126
|
+
if (words) say(r.problems);
|
|
127
|
+
}
|
|
128
|
+
return r.live;
|
|
129
|
+
};
|
|
130
|
+
const isApi = p => p.startsWith(apiAt) || p === apiAt.slice(0, -1);
|
|
131
|
+
const isPlatform = p => p.startsWith('/platform/v1/') && p !== LIVE_PATH && !p.startsWith('/platform/v1/dev/');
|
|
132
|
+
const isPage = (p, method) =>
|
|
133
|
+
(method === 'GET' || method === 'HEAD') &&
|
|
134
|
+
p.startsWith('/game/') &&
|
|
135
|
+
!isApi(p) &&
|
|
136
|
+
(p.endsWith('/') || /\.html?$/i.test(p));
|
|
137
|
+
const send = (res, status, type, body, extra = {}) => {
|
|
138
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', ...extra });
|
|
139
|
+
res.end(body);
|
|
140
|
+
return true;
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
current,
|
|
144
|
+
/** Whether a WebSocket upgrade to `pathname` is refused now (the game is paused). */
|
|
145
|
+
async refuseUpgrade(pathname) {
|
|
146
|
+
return isApi(pathname) && !!(await current()).maintenance;
|
|
147
|
+
},
|
|
148
|
+
async handle(req, res, url) {
|
|
149
|
+
const p = url.pathname;
|
|
150
|
+
if (p === LIVE_PATH && (req.method === 'GET' || req.method === 'HEAD'))
|
|
151
|
+
return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ ok: true, ...(await current()) }));
|
|
152
|
+
if (!isApi(p) && !isPlatform(p) && !isPage(p, req.method)) return false;
|
|
153
|
+
const m = (await current()).maintenance;
|
|
154
|
+
if (!m) return false;
|
|
155
|
+
if (isPage(p, req.method))
|
|
156
|
+
return send(res, 503, 'text/html; charset=utf-8', maintenancePage(m), { 'retry-after': '60' });
|
|
157
|
+
return send(res, 503, 'application/json; charset=utf-8', JSON.stringify(maintenanceBody(m)), {
|
|
158
|
+
'retry-after': '60',
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|