@xgamesfun/xcore 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
|
@@ -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
|
+
}
|
package/lib/dev/gf-local.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
6
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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),
|
|
98
|
-
*
|
|
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
|
-
{
|
|
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)
|
|
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,
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
},
|
package/lib/dev/limits.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// What `xcore dev` says about
|
|
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
|
|
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 }),
|