@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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
@@ -0,0 +1,247 @@
1
+ // The game's project files on this machine (xgames.fun's STORAGE.md §2, §8.6–8.7): what env.GF.files keeps, here in
2
+ // <game>/.gamesfun/dev/files/ (a hidden folder: never published), with the same paths, answers, limits and serving
3
+ // rules as xgames.fun's bucket and files domain:
4
+ // - createFileStore: put, get, head, list, delete, usage, as env.GF.files answers them (./gf-local.js calls them
5
+ // through ./platform.mjs), and `xcore files` without --live;
6
+ // - serveFiles: the public files on an origin of their own (another port of this machine), as
7
+ // <slug>.xgamesusercontent.com serves them: cross-origin to the game's page, as live, though same-site here (both
8
+ // are 127.0.0.1), where live xgamesusercontent.com is another site as well.
9
+ // Every answer is {ok: true, …} or {ok: false, code, why}; none throws.
10
+ import { createHash } from 'node:crypto';
11
+ import { createReadStream, existsSync } from 'node:fs';
12
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
13
+ import { createServer } from 'node:http';
14
+ import path from 'node:path';
15
+ import { FILES, filePathProblem, fileType, servedType } from '../server/contract.mjs';
16
+ import { stateFolder } from '../state-folder.mjs';
17
+
18
+ const no = (code, why) => ({ ok: false, code, why });
19
+
20
+ /** One game's files. `origin`: where the public ones are served (a function: the files server's port comes later). */
21
+ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes, fileBytes = FILES.fileBytes }) {
22
+ const root = path.join(dir, '.gamesfun', 'dev', 'files');
23
+ const index = path.join(dir, '.gamesfun', 'dev', 'files.json');
24
+ let meta = null,
25
+ chain = Promise.resolve();
26
+ // the index by path, on an object with no prototype: a file may be called __proto__, constructor or toString
27
+ const load = async () => {
28
+ if (meta) return meta;
29
+ let read = null;
30
+ try {
31
+ read = JSON.parse(await readFile(index, 'utf8'));
32
+ } catch {
33
+ /* none yet, or unreadable: empty */
34
+ }
35
+ meta = Object.create(null);
36
+ if (read && typeof read === 'object' && !Array.isArray(read))
37
+ for (const [k, v] of Object.entries(read)) if (v && typeof v === 'object') meta[k] = v;
38
+ return meta;
39
+ };
40
+ const at = (m, p) => (Object.hasOwn(m, p) ? m[p] : undefined);
41
+ const save = async () => {
42
+ await stateFolder(dir);
43
+ await mkdir(path.dirname(index), { recursive: true });
44
+ await writeFile(`${index}.tmp`, JSON.stringify(meta, null, 2) + '\n');
45
+ await rename(`${index}.tmp`, index);
46
+ };
47
+ // one change at a time, so the byte count and the files agree
48
+ const queued = fn => (chain = chain.then(fn, fn));
49
+ const fileOf = p => path.join(root, ...p.split('/'));
50
+ const used = m => Object.values(m).reduce((n, f) => n + f.bytes, 0);
51
+ const urlOf = p => {
52
+ const o = origin();
53
+ return o ? `${o}/${p}` : null;
54
+ };
55
+ const item = (p, f) => ({ path: p, bytes: f.bytes, type: f.type, public: f.public, updatedAt: f.updatedAt });
56
+
57
+ return {
58
+ root,
59
+ /** body: a string, bytes (ArrayBuffer, a typed array, a Buffer). → {ok, path, bytes, url?} */
60
+ put(p, body, { type, public: isPublic = false } = {}) {
61
+ return queued(async () => {
62
+ const bad = filePathProblem(p);
63
+ if (bad) return no('path', bad);
64
+ const bytes =
65
+ typeof body === 'string'
66
+ ? Buffer.from(body, 'utf8')
67
+ : body instanceof ArrayBuffer
68
+ ? Buffer.from(body)
69
+ : ArrayBuffer.isView(body)
70
+ ? Buffer.from(body.buffer, body.byteOffset, body.byteLength)
71
+ : null;
72
+ if (!bytes) return no('body', 'A file is a string, an ArrayBuffer or a stream of bytes.');
73
+ if (bytes.length > fileBytes)
74
+ return no(
75
+ 'too-big',
76
+ `A file may be ${Math.round(fileBytes / 1048576)} MB; this one is ${(bytes.length / 1048576).toFixed(1)} MB.`,
77
+ );
78
+ const m = await load();
79
+ const was = at(m, p)?.bytes ?? 0;
80
+ if (used(m) - was + bytes.length > cap)
81
+ return no('full', `The game's files would pass its ${Math.round(cap / 1048576)} MB: delete some first.`);
82
+ const file = fileOf(p);
83
+ // a file can't be where a folder is, nor the other way round
84
+ if (Object.keys(m).some(k => k.startsWith(`${p}/`) || p.startsWith(`${k}/`)))
85
+ return no('path', `${p} is a folder of other files, or inside a file: pick another path.`);
86
+ await mkdir(path.dirname(file), { recursive: true });
87
+ await writeFile(`${file}.gf-tmp`, bytes);
88
+ await rename(`${file}.gf-tmp`, file);
89
+ m[p] = {
90
+ bytes: bytes.length,
91
+ type: typeof type === 'string' && type ? type.slice(0, 200) : fileType(p),
92
+ public: !!isPublic,
93
+ updatedAt: Date.now(),
94
+ etag: createHash('sha256').update(bytes).digest('hex').slice(0, 32),
95
+ };
96
+ await save();
97
+ const url = m[p].public ? urlOf(p) : null;
98
+ return { ok: true, path: p, bytes: bytes.length, ...(url ? { url } : {}) };
99
+ });
100
+ },
101
+ /** → {ok, body (a Buffer), type, bytes, public} */
102
+ async get(p) {
103
+ const h = await this.head(p);
104
+ if (!h.ok) return h;
105
+ try {
106
+ return { ...h, body: await readFile(fileOf(p)) };
107
+ } catch {
108
+ return no('not-found', 'No such file.');
109
+ }
110
+ },
111
+ /** → {ok, path, type, bytes, public, updatedAt, etag} */
112
+ async head(p) {
113
+ const bad = filePathProblem(p);
114
+ if (bad) return no('path', bad);
115
+ await chain;
116
+ const f = at(await load(), p);
117
+ if (!f) return no('not-found', 'No such file.');
118
+ return { ok: true, ...item(p, f), etag: f.etag };
119
+ },
120
+ /** {prefix ('' or ending in /), after (the last path of the page before), limit ≤ 1000} → {ok, items, after} */
121
+ async list({ prefix = '', after = '', limit = 100 } = {}) {
122
+ if (typeof prefix !== 'string' || (prefix && (!prefix.endsWith('/') || filePathProblem(prefix.slice(0, -1)))))
123
+ return no('prefix', 'A prefix is empty, or a folder ending in /: "replays/".');
124
+ await chain;
125
+ const n = Math.max(1, Math.min(FILES.listMax, Number(limit) | 0 || 100));
126
+ const m = await load();
127
+ const names = Object.keys(m)
128
+ .filter(k => k.startsWith(prefix) && (!after || k > String(after)))
129
+ .sort();
130
+ const page = names.slice(0, n);
131
+ return {
132
+ ok: true,
133
+ items: page.map(k => item(k, m[k])),
134
+ after: names.length > n ? page.at(-1) : null,
135
+ };
136
+ },
137
+ /** → {ok, deleted} */
138
+ delete(p) {
139
+ return queued(async () => {
140
+ const bad = filePathProblem(p);
141
+ if (bad) return no('path', bad);
142
+ const m = await load();
143
+ if (!at(m, p)) return { ok: true, deleted: false };
144
+ delete m[p];
145
+ await rm(fileOf(p), { force: true });
146
+ await save();
147
+ return { ok: true, deleted: true };
148
+ });
149
+ },
150
+ /** → {ok, bytes, files, cap} */
151
+ async usage() {
152
+ await chain;
153
+ const m = await load();
154
+ return { ok: true, bytes: used(m), files: Object.keys(m).length, cap };
155
+ },
156
+ /** The file's bytes on disk, for the files server (a public file only). → {meta, file} | null */
157
+ async served(p) {
158
+ await chain;
159
+ const f = at(await load(), p);
160
+ if (!f?.public) return null;
161
+ const file = fileOf(p);
162
+ return existsSync(file) ? { meta: f, file, size: (await stat(file)).size } : null;
163
+ },
164
+ };
165
+ }
166
+
167
+ /** The headers every answer of the files domain carries (§8.7). */
168
+ export const FILE_HEADERS = Object.freeze({
169
+ 'x-content-type-options': 'nosniff',
170
+ 'content-security-policy': "sandbox; default-src 'none'",
171
+ 'cross-origin-resource-policy': 'cross-origin',
172
+ 'access-control-allow-origin': '*',
173
+ 'referrer-policy': 'no-referrer',
174
+ });
175
+
176
+ const isLocalHost = host =>
177
+ /^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)(:\d{1,5})?$/i.test(String(host ?? ''));
178
+
179
+ /**
180
+ * The public files on an origin of their own: GET, HEAD and OPTIONS; a path decoded once (an encoded / or a ..
181
+ * refused); only a public file; ETag and one byte range; media, fonts, JSON and plain text inline, anything else as
182
+ * application/octet-stream with Content-Disposition: attachment. → {origin, server, close()}
183
+ */
184
+ export function serveFiles(store, { port = 0, host = '127.0.0.1' } = {}) {
185
+ const server = createServer(async (req, res) => {
186
+ const send = (status, extra = {}, body = '') => {
187
+ res.writeHead(status, { ...FILE_HEADERS, 'cache-control': 'no-store', ...extra });
188
+ res.end(body);
189
+ };
190
+ if (!isLocalHost(req.headers.host)) return send(403, { 'content-type': 'text/plain' }, 'This machine only.');
191
+ if (req.method === 'OPTIONS')
192
+ return send(204, {
193
+ 'access-control-allow-methods': 'GET, HEAD, OPTIONS',
194
+ 'access-control-allow-headers': 'range',
195
+ });
196
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, { allow: 'GET, HEAD, OPTIONS' });
197
+ const raw = new URL(req.url, 'http://x').pathname.slice(1);
198
+ // decoded once: an encoded slash (a path that is not the one it looks like) or a dot segment is refused
199
+ let p;
200
+ try {
201
+ if (/%2f|%5c/i.test(raw)) throw new Error('slash');
202
+ p = decodeURIComponent(raw);
203
+ } catch {
204
+ return send(400, { 'content-type': 'text/plain' }, 'Not a file path.');
205
+ }
206
+ if (filePathProblem(p)) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
207
+ const f = await store.served(p);
208
+ if (!f) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
209
+ const { type, inline } = servedType(f.meta.type);
210
+ const etag = `"${f.meta.etag}"`;
211
+ const head = {
212
+ 'content-type': type,
213
+ etag,
214
+ 'accept-ranges': 'bytes',
215
+ 'last-modified': new Date(f.meta.updatedAt).toUTCString(),
216
+ ...(inline ? {} : { 'content-disposition': `attachment; filename="${path.basename(p)}"` }),
217
+ };
218
+ if (req.headers['if-none-match'] === etag) return send(304, head);
219
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ''));
220
+ let start = 0,
221
+ end = f.size - 1,
222
+ status = 200;
223
+ if (req.headers.range) {
224
+ if (!range || (range[1] === '' && range[2] === '')) return send(416, { 'content-range': `bytes */${f.size}` });
225
+ if (range[1] === '') start = Math.max(0, f.size - Number(range[2]));
226
+ else {
227
+ start = Number(range[1]);
228
+ if (range[2] !== '') end = Math.min(end, Number(range[2]));
229
+ }
230
+ if (start > end || start >= f.size) return send(416, { 'content-range': `bytes */${f.size}` });
231
+ status = 206;
232
+ head['content-range'] = `bytes ${start}-${end}/${f.size}`;
233
+ }
234
+ res.writeHead(status, { ...FILE_HEADERS, 'cache-control': 'no-store', ...head, 'content-length': end - start + 1 });
235
+ if (req.method === 'HEAD' || f.size === 0) return res.end();
236
+ createReadStream(f.file, { start, end })
237
+ .on('error', () => res.destroy())
238
+ .pipe(res);
239
+ });
240
+ return new Promise((resolve, reject) => {
241
+ server.once('error', reject);
242
+ server.listen(port, host, () => {
243
+ const origin = `http://${host}:${server.address().port}`;
244
+ resolve({ origin, server, close: () => new Promise(r => server.close(() => r())) });
245
+ });
246
+ });
247
+ }
@@ -1,9 +1,10 @@
1
1
  // env.GF on this machine: the platform binding a game's server gets in `xcore dev` (gf-server-v1,
2
2
  // ../server/contract.mjs GF_METHODS). It runs in workerd beside the game's server as a Workers RPC entrypoint, the way
3
- // games.fun binds its platform (entrypoint GameServices) to a hosted game's server, with the same methods, arguments
3
+ // xgames.fun binds its platform (entrypoint GameServices) to a hosted game's server, with the same methods, arguments
4
4
  // and answers. Every call goes to the dev host (the HOST binding, a function in Node: ./platform.mjs), which keeps saves
5
- // and scores in the game's .gamesfun/dev-platform.json, checks its own local sign-in tokens, and prints loot reports.
6
- // Nothing here reaches games.fun. Every answer is {ok: true, …} or {ok: false, code, why}; nothing throws.
5
+ // and scores in the game's .gamesfun/dev-platform.json and project files in .gamesfun/dev/files/ (env.GF.files), checks
6
+ // its own local sign-in tokens, and prints loot reports.
7
+ // Nothing here reaches xgames.fun. Every answer is {ok: true, …} or {ok: false, code, why}; nothing throws.
7
8
  import { WorkerEntrypoint, RpcTarget } from 'cloudflare:workers';
8
9
 
9
10
  async function call(env, op, args) {
@@ -81,7 +82,72 @@ class Loot extends RpcTarget {
81
82
  }
82
83
  }
83
84
 
85
+ // the game's project files (STORAGE.md §8.6): the bytes go to the dev host as they are, not as JSON
86
+ const unavailable = e => ({
87
+ ok: false,
88
+ code: 'unavailable',
89
+ why: `xcore dev's platform did not answer (${String(e?.message ?? e).slice(0, 120)}).`,
90
+ });
91
+ class Files extends RpcTarget {
92
+ #env;
93
+ constructor(env) {
94
+ super();
95
+ this.#env = env;
96
+ }
97
+ // a stream is held to exactly its declared `length`; one with none is read up to 1 MB (as xgames.fun's put)
98
+ async put(path, body, { type, public: isPublic = false, length = null } = {}) {
99
+ const stream = typeof ReadableStream !== 'undefined' && body instanceof ReadableStream;
100
+ const ok = typeof body === 'string' || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || stream;
101
+ if (!ok) return { ok: false, code: 'body', why: 'A file is a string, an ArrayBuffer or a stream of bytes.' };
102
+ if (stream && length !== null && length !== undefined && !(Number.isSafeInteger(length) && length >= 0)) {
103
+ await body.cancel?.().catch(() => {});
104
+ return { ok: false, code: 'body', why: 'A put’s {length} is a whole number of bytes.' };
105
+ }
106
+ const q = new URLSearchParams({
107
+ path: String(path),
108
+ ...(typeof type === 'string' ? { type } : {}),
109
+ public: isPublic ? '1' : '',
110
+ ...(stream ? { stream: '1' } : {}),
111
+ ...(stream && length !== null && length !== undefined ? { length: String(length) } : {}),
112
+ });
113
+ try {
114
+ const r = await this.#env.HOST.fetch(`http://gf.local/files.put?${q}`, { method: 'POST', body });
115
+ return await r.json();
116
+ } catch (e) {
117
+ return unavailable(e);
118
+ }
119
+ }
120
+ /** → {ok, body (a ReadableStream), type, bytes, public} */
121
+ async get(path) {
122
+ try {
123
+ const r = await this.#env.HOST.fetch('http://gf.local/files.get', {
124
+ method: 'POST',
125
+ body: JSON.stringify([path]),
126
+ });
127
+ const meta = r.headers.get('x-gf-answer');
128
+ return meta ? { ...JSON.parse(meta), body: r.body } : await r.json();
129
+ } catch (e) {
130
+ return unavailable(e);
131
+ }
132
+ }
133
+ head(path) {
134
+ return call(this.#env, 'files.head', [path]);
135
+ }
136
+ list(opts = {}) {
137
+ return call(this.#env, 'files.list', [opts]);
138
+ }
139
+ delete(path) {
140
+ return call(this.#env, 'files.delete', [path]);
141
+ }
142
+ usage() {
143
+ return call(this.#env, 'files.usage', []);
144
+ }
145
+ }
146
+
84
147
  export class GameServices extends WorkerEntrypoint {
148
+ get files() {
149
+ return new Files(this.env);
150
+ }
85
151
  get saves() {
86
152
  return new Saves(this.env);
87
153
  }
package/lib/dev/index.mjs CHANGED
@@ -1,10 +1,14 @@
1
- // `xcore dev`: a game as games.fun hosts it, on this machine, with everything behind it that the play host gives a
1
+ // `xcore dev`: a game as xgames.fun hosts it, on this machine, with everything behind it that the play host gives a
2
2
  // published game, so the whole game (page, server, sign-in, saves, rooms, economy) runs before it is published:
3
3
  // /game/* the published files (gamesfun.json page.out), with the play host's headers (../check.mjs servePage)
4
- // /game/api/* the game's own server/ under workerd (./server.mjs), WebSockets too, sent on as /api/*
4
+ // /game/api/* the game's own server/ under workerd (./server.mjs), WebSockets too, sent on as /api/*; for a
5
+ // game with a database and no server/, xcore's stock server, with a local D1 (./d1.mjs)
5
6
  // /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well
7
+ // <files origin>/* the game's public project files (env.GF.files, xcore files), on a port of its own: another
8
+ // origin to the browser (cross-origin, though same-site: both are 127.0.0.1), as
9
+ // <slug>.xgamesusercontent.com is live, where it is another site too (./files.mjs)
6
10
  // /loot/v1/rpc the economy's Solana RPC: forwarded to XSOLANA_RPC_URL when set (your own devnet RPC, its URL
7
- // never shown), else to games.fun's shared relay, the one a published page talks to
11
+ // never shown), else to xgames.fun's shared relay, the one a published page talks to
8
12
  // Every HTML page served gets a one-line marker (globalThis.GAMESFUN_DEV) so gamesfun.js uses this host's sign-in,
9
13
  // saves, database and rooms instead of the browser-only stand-ins it uses on a plain local server.
10
14
  import { existsSync, readFileSync, watch } from 'node:fs';
@@ -17,6 +21,7 @@ import { readServerConfig, API_PREFIX } from '../server/contract.mjs';
17
21
  import { PAGE_RPC, rpcWords, solanaRpc } from '../solana-rpc.mjs';
18
22
  import { handlePlatform, createPlatformStore } from './platform.mjs';
19
23
  import { createLimitWatch } from './limits.mjs';
24
+ import { serveFiles } from './files.mjs';
20
25
 
21
26
  const readJson = file => {
22
27
  try {
@@ -25,6 +30,10 @@ const readJson = file => {
25
30
  return null;
26
31
  }
27
32
  };
33
+ /** What the dev log says about the game's local database. */
34
+ const databaseLine = d =>
35
+ `database: env.${d.binding}, a local D1 kept in .gamesfun/dev/d1/${d.migrated.length ? ` (applied ${d.migrated.join(', ')})` : ''}`;
36
+
28
37
  export const DEV_MARK = '<script>globalThis.GAMESFUN_DEV=1</script>';
29
38
  /** The marker gamesfun.js reads, first thing in <head> (or the page) so it is set before any script runs. */
30
39
  export function markPage(html) {
@@ -37,7 +46,7 @@ export function markPage(html) {
37
46
  : DEV_MARK + html;
38
47
  }
39
48
 
40
- // ---- what games.fun's shared relay takes (its rules, said here so a game run against your own RPC hears what the
49
+ // ---- what xgames.fun's shared relay takes (its rules, said here so a game run against your own RPC hears what the
41
50
  // published page would be refused) ------------------------------------------------------------------------------
42
51
  export const RELAY_METHODS = Object.freeze([
43
52
  'getAccountInfo',
@@ -71,7 +80,7 @@ function scanAllowed(program, filters) {
71
80
  const at = Object.hasOwn(RELAY_SCANS, program) ? RELAY_SCANS[program] : null;
72
81
  return at !== null && filters.some(f => f?.memcmp?.offset === at && ADDRESS.test(String(f.memcmp.bytes ?? '')));
73
82
  }
74
- /** Would games.fun's shared relay refuse this JSON-RPC body? → null, or why */
83
+ /** Would xgames.fun's shared relay refuse this JSON-RPC body? → null, or why */
75
84
  export function relayRefuses(call) {
76
85
  if (Array.isArray(call)) return 'a batch (the relay takes one request a call)';
77
86
  if (!call || typeof call.method !== 'string') return 'not a JSON-RPC request';
@@ -94,8 +103,10 @@ async function body(req, cap = 256 * 1024) {
94
103
  }
95
104
 
96
105
  /**
97
- * Start the dev host for `dir`. Options: port (default 8787; 0 any), noBuild, log, env, fetchImpl (the RPC forward's).
98
- * → {url, origin, rpc, server (the game server's status, or null), store, reload(), close()}
106
+ * Start the dev host for `dir`. Options: port (default 8787; 0 any), filesPort (default the next one; 0 any), noBuild,
107
+ * log, env, fetchImpl (the RPC forward's).
108
+ * → {url, origin, files (the public files' origin), rpc, server (the game server's status, or null), store, reload(),
109
+ * close()}
99
110
  */
100
111
  /** A Host header naming this machine: localhost, 127.0.0.1, [::1] or a name under .localhost, with any port. */
101
112
  export const isLocalHost = host =>
@@ -103,7 +114,14 @@ export const isLocalHost = host =>
103
114
 
104
115
  export async function startDev(
105
116
  dir,
106
- { port = 8787, noBuild = false, log = m => console.log(m), env = process.env, fetchImpl = fetch } = {},
117
+ {
118
+ port = 8787,
119
+ filesPort = port ? port + 1 : 0,
120
+ noBuild = false,
121
+ log = m => console.log(m),
122
+ env = process.env,
123
+ fetchImpl = fetch,
124
+ } = {},
107
125
  ) {
108
126
  const game = path.resolve(dir);
109
127
  const roots = pageRoots(game);
@@ -121,13 +139,24 @@ export async function startDev(
121
139
  );
122
140
 
123
141
  const store = createPlatformStore({ dir: game, log: m => log(`platform: ${m}`) });
142
+ // the game's public files on an origin of their own, as <slug>.xgamesusercontent.com is to the published game:
143
+ // another port is another origin (no sign-in, no calls to the game's api/), though the same site here (both are
144
+ // 127.0.0.1); live, xgamesusercontent.com is another site as well
145
+ const files = await serveFiles(store.files, { port: filesPort }).catch(e =>
146
+ e.code === 'EADDRINUSE' && filesPort ? serveFiles(store.files, { port: 0 }) : Promise.reject(e),
147
+ );
148
+ store.setFilesOrigin(files.origin);
124
149
  const cfg = await readServerConfig(game);
125
150
  let server = null;
126
151
  if (cfg.has) {
127
152
  const { startLocalServer } = await import('./server.mjs');
128
153
  server = await startLocalServer({ dir: game, store, log: m => log(`server: ${m}`), env });
129
154
  const s = server.status();
130
- if (s.running) log(`server: running${s.objects.length ? ` (${s.objects.join(', ')})` : ''}`);
155
+ if (s.running)
156
+ log(
157
+ `server: running${s.stock ? " (xcore's stock server: the page's api/db/)" : s.objects.length ? ` (${s.objects.join(', ')})` : ''}`,
158
+ );
159
+ if (s.database) log(databaseLine(s.database));
131
160
  else for (const p of [s.error, ...s.problems].filter(Boolean)) log(`server: ${p}`);
132
161
  for (const w of s.warnings) log(`server: ${w}`);
133
162
  }
@@ -149,7 +178,7 @@ export async function startDev(
149
178
  const why = relayRefuses(call);
150
179
  if (why && !warned.has(why)) {
151
180
  warned.add(why);
152
- log(`rpc: published, games.fun's shared relay would refuse ${why}; it reached your own RPC here`);
181
+ log(`rpc: published, xgames.fun's shared relay would refuse ${why}; it reached your own RPC here`);
153
182
  }
154
183
  }
155
184
  let a;
@@ -213,14 +242,23 @@ export async function startDev(
213
242
  return false;
214
243
  };
215
244
  const backend = backendOrigins(readJson(pageFile(roots, BACKEND_FILE)), { own: ownOrigins() }).origins;
216
- const site = await servePage(roots.out, {
217
- backend,
218
- server: cfg.has,
219
- route,
220
- html: markPage,
221
- port,
222
- overlay: roots.overlay,
223
- });
245
+ let site;
246
+ try {
247
+ site = await servePage(roots.out, {
248
+ backend,
249
+ files: [files.origin],
250
+ server: cfg.has,
251
+ route,
252
+ html: markPage,
253
+ port,
254
+ overlay: roots.overlay,
255
+ });
256
+ } catch (e) {
257
+ // the page could not be served (a port in use): the game's server started above stops with it
258
+ if (server) await server.close().catch(() => {});
259
+ await files.close();
260
+ throw e;
261
+ }
224
262
  site.server.on('upgrade', (req, socket, head) => {
225
263
  if (!isLocalHost(req.headers.host)) return socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
226
264
  const u = new URL(req.url, 'http://x');
@@ -234,8 +272,11 @@ export async function startDev(
234
272
  // that the server imports from outside server/ (rules shared with the page) rebuilds and reloads it
235
273
  const watchers = [];
236
274
  const shared = new Map(); // a file the server's bundle reads from outside server/ → its watcher
275
+ // a change waits 200 ms for the next (an editor saves several files): `timer`, cleared by close(), after which no
276
+ // reload starts (closed)
277
+ let timer = null,
278
+ closed = false;
237
279
  if (server) {
238
- let timer = null;
239
280
  const watchShared = () => {
240
281
  const want = new Set(
241
282
  (server.status().inputs ?? [])
@@ -259,13 +300,16 @@ export async function startDev(
259
300
  }
260
301
  };
261
302
  const again = what => {
303
+ if (closed) return;
262
304
  clearTimeout(timer);
263
305
  timer = setTimeout(async () => {
306
+ timer = null;
264
307
  const s = await server.reload();
308
+ if (closed) return;
265
309
  watchShared();
266
310
  log(
267
311
  s.running
268
- ? `server: reloaded (${what} changed)`
312
+ ? `server: reloaded (${what} changed)${s.database?.migrated.length ? `; ${databaseLine(s.database).slice('database: '.length)}` : ''}`
269
313
  : `server: ${what} changed, and it does not start: ${[s.error, ...s.problems].filter(Boolean).join('; ')}`,
270
314
  );
271
315
  }, 200);
@@ -292,13 +336,17 @@ export async function startDev(
292
336
  return {
293
337
  url: site.url,
294
338
  origin: site.origin,
339
+ files: files.origin,
295
340
  rpc: rpcWords(rpc),
296
341
  server: server ? server.status() : null,
297
342
  store,
298
343
  reload: () => server?.reload(),
299
344
  async close() {
345
+ closed = true;
346
+ clearTimeout(timer);
300
347
  for (const w of [...watchers, ...shared.values()]) w.close();
301
348
  await site.close();
349
+ await files.close();
302
350
  if (server) await server.close();
303
351
  else await store.flush();
304
352
  },
@@ -1,9 +1,9 @@
1
- // What `xcore dev` says about games.fun's plan limits (README §5, `xcore plan`). The local server runs with no CPU or
1
+ // What `xcore dev` says about xgames.fun's plan limits (README §5, `xcore plan`). The local server runs with no CPU or
2
2
  // request limits, so a page that polls its server ten times a second works here and is refused once published. This
3
3
  // counts the page's requests to its server and times each one, and says so in the dev log, once a minute at most for
4
4
  // the rate and once per route for the time. It never refuses anything itself.
5
5
  //
6
- // The numbers are games.fun's plans; games.fun is the source and can change them.
6
+ // The numbers are xgames.fun's plans; xgames.fun is the source and can change them.
7
7
  export const PLAN_LIMITS = Object.freeze({
8
8
  free: Object.freeze({ name: 'Free', perPlayer: 120, cpuMs: 50 }),
9
9
  creator: Object.freeze({ name: 'Creator', perPlayer: 600, cpuMs: 200 }),