@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/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,8 @@
|
|
|
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)
|
|
7
8
|
// /platform/v1/live the game's announcement, config and maintenance, from .gamesfun/live.json (./live.mjs); while
|
|
8
9
|
// maintenance is on there, the holding page for its HTML and 503 'maintenance' for its api/
|
|
9
10
|
// and platform calls, as xgames.fun's play edge does
|
|
@@ -25,6 +26,7 @@ import { PAGE_RPC, rpcWords, solanaRpc } from '../solana-rpc.mjs';
|
|
|
25
26
|
import { handlePlatform, createPlatformStore } from './platform.mjs';
|
|
26
27
|
import { createLiveRoutes, readLive, LIVE_FILE } from './live.mjs';
|
|
27
28
|
import { createLimitWatch } from './limits.mjs';
|
|
29
|
+
import { devPlan, planLine } from './plans.mjs';
|
|
28
30
|
import { serveFiles } from './files.mjs';
|
|
29
31
|
|
|
30
32
|
const readJson = file => {
|
|
@@ -106,20 +108,27 @@ async function body(req, cap = 256 * 1024) {
|
|
|
106
108
|
return Buffer.concat(chunks);
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
|
|
115
117
|
/** A Host header naming this machine: localhost, 127.0.0.1, [::1] or a name under .localhost, with any port. */
|
|
116
118
|
export const isLocalHost = host =>
|
|
117
119
|
/^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)(:\d{1,5})?$/i.test(String(host ?? ''));
|
|
118
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
|
+
*/
|
|
119
127
|
export async function startDev(
|
|
120
128
|
dir,
|
|
121
129
|
{
|
|
122
130
|
port = 8787,
|
|
131
|
+
plan: planName = 'free',
|
|
123
132
|
filesPort = port ? port + 1 : 0,
|
|
124
133
|
noBuild = false,
|
|
125
134
|
log = m => console.log(m),
|
|
@@ -128,6 +137,7 @@ export async function startDev(
|
|
|
128
137
|
} = {},
|
|
129
138
|
) {
|
|
130
139
|
const game = path.resolve(dir);
|
|
140
|
+
const plan = devPlan(planName);
|
|
131
141
|
const roots = pageRoots(game);
|
|
132
142
|
if (roots.build && !noBuild) {
|
|
133
143
|
log(`build: ${roots.build}`);
|
|
@@ -142,7 +152,9 @@ export async function startDev(
|
|
|
142
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>)"}`,
|
|
143
153
|
);
|
|
144
154
|
|
|
145
|
-
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);
|
|
146
158
|
// the game's public files on an origin of their own, as <slug>.xgamesusercontent.com is to the published game:
|
|
147
159
|
// another port is another origin (no sign-in, no calls to the game's api/), though the same site here (both are
|
|
148
160
|
// 127.0.0.1); live, xgamesusercontent.com is another site as well
|
|
@@ -219,7 +231,7 @@ export async function startDev(
|
|
|
219
231
|
res.end(text);
|
|
220
232
|
}
|
|
221
233
|
|
|
222
|
-
const limits = createLimitWatch({ log });
|
|
234
|
+
const limits = createLimitWatch({ log, plan });
|
|
223
235
|
const apiAt = `/game${API_PREFIX}`; // the page's api/ beside it: /game/api/
|
|
224
236
|
const live = createLiveRoutes({ dir: game, apiAt, say: problems => log(`live: ${problems.join('; ')}`) });
|
|
225
237
|
const said = await readLive(game);
|
|
@@ -239,6 +251,16 @@ export async function startDev(
|
|
|
239
251
|
}
|
|
240
252
|
if (url.pathname === PAGE_RPC) return (await forwardRpc(req, res), true);
|
|
241
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}`);
|
|
242
264
|
if (await handlePlatform(req, res, url, store)) return true;
|
|
243
265
|
if (url.pathname.startsWith(apiAt) || url.pathname === apiAt.slice(0, -1)) {
|
|
244
266
|
const rest = url.pathname.slice('/game'.length) + url.search;
|
|
@@ -354,6 +376,7 @@ export async function startDev(
|
|
|
354
376
|
origin: site.origin,
|
|
355
377
|
files: files.origin,
|
|
356
378
|
rpc: rpcWords(rpc),
|
|
379
|
+
plan: plan.key,
|
|
357
380
|
server: server ? server.status() : null,
|
|
358
381
|
store,
|
|
359
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
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The numbers xgames.fun's platform holds a published game to, as `xcore dev` imitates them: one table, so a change
|
|
2
|
+
// on xgames.fun is one edit here. The source is xgames.fun's own plan table, the one its platform enforces (README
|
|
3
|
+
// "Plans and limits"; `xcore plan` shows a published game's own numbers). PLANS changes with the game's plan; RULES
|
|
4
|
+
// are the same on every plan. `xcore dev --plan <name>` picks the plan the dev host imitates (Free by default).
|
|
5
|
+
const deepFreeze = o => {
|
|
6
|
+
for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
|
|
7
|
+
return Object.freeze(o);
|
|
8
|
+
};
|
|
9
|
+
const KB = 1024,
|
|
10
|
+
MB = 1024 * KB,
|
|
11
|
+
GB = 1024 * MB;
|
|
12
|
+
|
|
13
|
+
export const PLANS = deepFreeze({
|
|
14
|
+
free: {
|
|
15
|
+
name: 'Free',
|
|
16
|
+
cpuMs: 50,
|
|
17
|
+
rate: { player: 120 },
|
|
18
|
+
saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: 10 * MB, writesPerMinute: 600 },
|
|
19
|
+
scores: { boards: 8, rules: 16, entriesPerBoard: 10_000, submitsPerMinute: 600 },
|
|
20
|
+
reads: { perMinute: 1_200 },
|
|
21
|
+
files: { bytes: 100 * MB, fileBytes: 25 * MB, opsPerMinute: 120 },
|
|
22
|
+
},
|
|
23
|
+
creator: {
|
|
24
|
+
name: 'Creator',
|
|
25
|
+
cpuMs: 200,
|
|
26
|
+
rate: { player: 600 },
|
|
27
|
+
saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: GB, writesPerMinute: 12_000 },
|
|
28
|
+
scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 6_000 },
|
|
29
|
+
reads: { perMinute: 12_000 },
|
|
30
|
+
files: { bytes: 10 * GB, fileBytes: 25 * MB, opsPerMinute: 1_200 },
|
|
31
|
+
},
|
|
32
|
+
scale: {
|
|
33
|
+
name: 'Scale',
|
|
34
|
+
cpuMs: 1_000,
|
|
35
|
+
rate: { player: 1_200 },
|
|
36
|
+
saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: 10 * GB, writesPerMinute: 60_000 },
|
|
37
|
+
scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 30_000 },
|
|
38
|
+
reads: { perMinute: 60_000 },
|
|
39
|
+
files: { bytes: 100 * GB, fileBytes: 25 * MB, opsPerMinute: 6_000 },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
export const PLAN_NAMES = Object.freeze(Object.keys(PLANS));
|
|
43
|
+
export const DEFAULT_PLAN = 'free';
|
|
44
|
+
|
|
45
|
+
export const RULES = deepFreeze({
|
|
46
|
+
saves: {
|
|
47
|
+
playerChars: 128,
|
|
48
|
+
// a page's writes may keep at most 1/128 of the game's save bytes for one player (never less than one save)
|
|
49
|
+
playerShareDivisor: 128,
|
|
50
|
+
// with the game's saves full, a player whose saves stay within 4 KB still writes, up to an eighth past the total
|
|
51
|
+
floorBytes: 4 * KB,
|
|
52
|
+
floorPoolDivisor: 8,
|
|
53
|
+
},
|
|
54
|
+
scores: { nameChars: 32, perPlayerEveryMs: 1_000, topMax: 100, offsetMax: 10_000, rankMax: 10_000 },
|
|
55
|
+
// the page's own calls (/platform/v1/*): the same on every plan
|
|
56
|
+
page: {
|
|
57
|
+
bodyBytes: 96 * KB,
|
|
58
|
+
saveWritesPerPlayerMinute: 60,
|
|
59
|
+
scoreSubmitsPerPlayerMinute: 30,
|
|
60
|
+
// once pages have spent the game's minute, a player with at most this many this minute still writes
|
|
61
|
+
saveWritesFloorPerPlayerMinute: 6,
|
|
62
|
+
scoreSubmitsFloorPerPlayerMinute: 3,
|
|
63
|
+
// boards a page makes: "high" only, two a player, and half the plan's boards for all pages together
|
|
64
|
+
boardsPerPlayer: 2,
|
|
65
|
+
boardsDivisor: 2,
|
|
66
|
+
// a board read with no good player token: the top 100 only, an identical read within 5 s from a cache, and a
|
|
67
|
+
// minute of its own, a tenth of the plan's reads
|
|
68
|
+
anonTopDepth: 100,
|
|
69
|
+
anonReadsDivisor: 10,
|
|
70
|
+
anonReadCacheMs: 5_000,
|
|
71
|
+
},
|
|
72
|
+
// the play edge's own limit on reads of /platform/v1 from one address (xcore dev only says it: see limits.mjs)
|
|
73
|
+
edge: { platformReadsPerCallerMinute: 120 },
|
|
74
|
+
// what GF.files.put holds in memory: a string or ArrayBuffer body, or a stream with no declared length
|
|
75
|
+
files: { unsizedBytes: MB },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** The plan `xcore dev` imitates: 'free', 'creator' or 'scale' (any case). → {key, ...PLANS[key]}; throws on another. */
|
|
79
|
+
export function devPlan(name = DEFAULT_PLAN) {
|
|
80
|
+
const key = String(name ?? DEFAULT_PLAN)
|
|
81
|
+
.trim()
|
|
82
|
+
.toLowerCase();
|
|
83
|
+
if (!Object.hasOwn(PLANS, key)) throw new Error(`there is no plan "${name}": ${PLAN_NAMES.join(', ')}`);
|
|
84
|
+
return { key, ...PLANS[key] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const count = n => n.toLocaleString('en-US');
|
|
88
|
+
/** A size in words: 10 MB, 1 GB, 80 KB. */
|
|
89
|
+
export const size = b =>
|
|
90
|
+
b >= GB ? `${count(b / GB)} GB` : b >= MB ? `${count(b / MB)} MB` : `${count(Math.round(b / KB))} KB`;
|
|
91
|
+
export { count };
|
|
92
|
+
|
|
93
|
+
/** The dev host's first line about the plan it imitates. */
|
|
94
|
+
export function planLine(plan) {
|
|
95
|
+
const others = PLAN_NAMES.filter(k => k !== plan.key).join('|');
|
|
96
|
+
return (
|
|
97
|
+
`platform: imitating the ${plan.name} plan (--plan ${others} for another): ${plan.scores.boards} boards, ` +
|
|
98
|
+
`${size(plan.saves.gameBytes)} of saves, ${count(plan.reads.perMinute)} reads and ` +
|
|
99
|
+
`${count(plan.saves.writesPerMinute)} saves a minute, ${size(plan.files.bytes)} of files`
|
|
100
|
+
);
|
|
101
|
+
}
|