@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
package/lib/dev/platform.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// - the game's server, through ./gf-local.js's HOST binding (handleHost),
|
|
5
5
|
// - the page, through the dev host's /platform/v1/* routes (handlePlatform, below).
|
|
6
6
|
// Kept in <game>/.gamesfun/dev-platform.json (a hidden folder: never published), written a moment after each change.
|
|
7
|
+
// The game's project files (env.GF.files) are ./files.mjs's, in <game>/.gamesfun/dev/files/.
|
|
7
8
|
//
|
|
8
|
-
// Every answer has
|
|
9
|
+
// Every answer has xgames.fun's shape: {ok: true, …} or {ok: false, code, why}. A refusal is an answer, never a throw,
|
|
9
10
|
// so a game's server reads it the same here as hosted. The limits are the hosted ones (PLATFORM_LIMITS), so a game
|
|
10
11
|
// that stays inside them here stays inside them there; the per-minute rate limits are left out on this machine.
|
|
11
12
|
//
|
|
12
|
-
// Local sign-in. No
|
|
13
|
+
// Local sign-in. No xgames.fun signs a player in on this machine, so the dev host hands out its own tokens
|
|
13
14
|
// (POST /platform/v1/dev/token): `gfdev1.<payload>.<HMAC-SHA256>`, signed with a key made when it starts and never
|
|
14
|
-
// written anywhere, for this game only, lasting an hour. verifyPlayer accepts exactly those;
|
|
15
|
+
// written anywhere, for this game only, lasting an hour. verifyPlayer accepts exactly those; an xgames.fun token (EdDSA,
|
|
15
16
|
// from the play host) is refused here with words that say so.
|
|
16
17
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
18
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
@@ -22,9 +23,14 @@ import {
|
|
|
22
23
|
SAVE_KEY,
|
|
23
24
|
SERVER_FORMAT,
|
|
24
25
|
checkLootResult,
|
|
26
|
+
FILES,
|
|
25
27
|
} from '../server/contract.mjs';
|
|
28
|
+
import { createFileStore } from './files.mjs';
|
|
29
|
+
import { stateFolder } from '../state-folder.mjs';
|
|
26
30
|
|
|
27
31
|
const TOKEN_MS = 60 * 60 * 1000;
|
|
32
|
+
/** How much of a stream with no declared length GF.files.put reads (xgames.fun's UNSIZED_MAX); a longer one needs it. */
|
|
33
|
+
const UNSIZED_MAX = 1024 * 1024;
|
|
28
34
|
// eslint-disable-next-line no-control-regex -- it exists to drop control characters
|
|
29
35
|
const UNSAFE = /[\u0000-\u001f<>]/g;
|
|
30
36
|
const NAME = s =>
|
|
@@ -37,7 +43,7 @@ const okPlayer = p => typeof p === 'string' && p.length >= 1 && p.length <= L.sa
|
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
45
|
* One game's platform store. → {slug, gameId, run(op, args), handleHost(request), devToken({name, id}),
|
|
40
|
-
* verifyPlayer(token), flush(), file}
|
|
46
|
+
* verifyPlayer(token), flush(), file, files (./files.mjs), setFilesOrigin(origin)}
|
|
41
47
|
*/
|
|
42
48
|
export function createPlatformStore({
|
|
43
49
|
dir,
|
|
@@ -48,11 +54,21 @@ export function createPlatformStore({
|
|
|
48
54
|
} = {}) {
|
|
49
55
|
const key = randomBytes(32);
|
|
50
56
|
const gameId = `dev-${slug}`.slice(0, 60);
|
|
57
|
+
// the public files' origin: the dev host's files server, once it listens
|
|
58
|
+
let filesOrigin = null;
|
|
59
|
+
const files = createFileStore({ dir, origin: () => filesOrigin });
|
|
51
60
|
let data = null,
|
|
61
|
+
loading = null,
|
|
52
62
|
timer = null,
|
|
53
63
|
writing = Promise.resolve();
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
// the file is read once: calls that come at once before it is read all wait on that one read, so none of them puts
|
|
65
|
+
// a fresh copy of the file over what another has changed already
|
|
66
|
+
const load = () =>
|
|
67
|
+
(loading ??= readStore().catch(e => {
|
|
68
|
+
loading = null; // read again next time
|
|
69
|
+
throw e;
|
|
70
|
+
}));
|
|
71
|
+
const readStore = async () => {
|
|
56
72
|
try {
|
|
57
73
|
data = JSON.parse(await readFile(file, 'utf8'));
|
|
58
74
|
} catch {
|
|
@@ -72,6 +88,7 @@ export function createPlatformStore({
|
|
|
72
88
|
return data;
|
|
73
89
|
};
|
|
74
90
|
const write = async () => {
|
|
91
|
+
if (dir && path.dirname(file) === path.join(dir, '.gamesfun')) await stateFolder(dir);
|
|
75
92
|
await mkdir(path.dirname(file), { recursive: true });
|
|
76
93
|
await writeFile(`${file}.tmp`, JSON.stringify(data, null, 2) + '\n');
|
|
77
94
|
await rename(`${file}.tmp`, file);
|
|
@@ -99,7 +116,7 @@ export function createPlatformStore({
|
|
|
99
116
|
expires: exp * 1000,
|
|
100
117
|
};
|
|
101
118
|
}
|
|
102
|
-
/** env.GF.verifyPlayer, on this machine:
|
|
119
|
+
/** env.GF.verifyPlayer, on this machine: xgames.fun's answer shape (no name: hosted it has none), plus `dev: true`. */
|
|
103
120
|
function verifyPlayer(token) {
|
|
104
121
|
const { name: _name, ...v } = readToken(token);
|
|
105
122
|
return v;
|
|
@@ -111,7 +128,7 @@ export function createPlatformStore({
|
|
|
111
128
|
if (parts[0] !== 'gfdev1' || parts.length !== 3)
|
|
112
129
|
return no(
|
|
113
130
|
'bad',
|
|
114
|
-
'In xcore dev only its own local sign-in tokens are checked (GamesFun.signIn() gets one);
|
|
131
|
+
'In xcore dev only its own local sign-in tokens are checked (GamesFun.signIn() gets one); an xgames.fun token is checked when the game is hosted.',
|
|
115
132
|
);
|
|
116
133
|
const want = Buffer.from(sign(`gfdev1.${parts[1]}`)),
|
|
117
134
|
got = Buffer.from(parts[2]);
|
|
@@ -213,7 +230,7 @@ export function createPlatformStore({
|
|
|
213
230
|
persist();
|
|
214
231
|
return { ok: true, improved, best: row.score, rank: rankOf(b, row.score) };
|
|
215
232
|
},
|
|
216
|
-
/** The board's settings, made now when new: {order?, writer?: 'server' | 'any'} (
|
|
233
|
+
/** The board's settings, made now when new: {order?, writer?: 'server' | 'any'} (xgames.fun's GF.scores.setup).
|
|
217
234
|
* `board` may be a pattern (`daily-*`, `*`): then only the rule is kept, for those boards now and to come. */
|
|
218
235
|
async 'scores.setup'(board, { order = 'high', writer } = {}) {
|
|
219
236
|
const d = await load();
|
|
@@ -280,7 +297,7 @@ export function createPlatformStore({
|
|
|
280
297
|
if (d.reports.length > 200) d.reports.shift();
|
|
281
298
|
persist();
|
|
282
299
|
log(
|
|
283
|
-
`loot.report ${entry.id} (on this machine nothing is signed or paid; hosted,
|
|
300
|
+
`loot.report ${entry.id} (on this machine nothing is signed or paid; hosted, xgames.fun's keeper signs it with the game's host key; a voucher key of the server's own, xcore add vouchers, signs here too): ${JSON.stringify(result).slice(0, 300)}`,
|
|
284
301
|
);
|
|
285
302
|
return { ok: true, result: null, vouchers: [], local: true, id: entry.id };
|
|
286
303
|
},
|
|
@@ -293,6 +310,10 @@ export function createPlatformStore({
|
|
|
293
310
|
async info() {
|
|
294
311
|
return { ok: true, contract: SERVER_FORMAT, gameId, slug, local: true };
|
|
295
312
|
},
|
|
313
|
+
'files.head': p => files.head(p),
|
|
314
|
+
'files.list': opts => files.list(opts ?? {}),
|
|
315
|
+
'files.delete': p => files.delete(p),
|
|
316
|
+
'files.usage': () => files.usage(),
|
|
296
317
|
};
|
|
297
318
|
|
|
298
319
|
/** The board's writer: its own rule, else the longest pattern (`daily-*`, `*`) its name starts with, else 'any'. */
|
|
@@ -360,7 +381,54 @@ export function createPlatformStore({
|
|
|
360
381
|
}
|
|
361
382
|
/** gf-local.js's HOST binding: POST http://gf.local/<op> [args…] → the answer as JSON. */
|
|
362
383
|
async function handleHost(request) {
|
|
363
|
-
const
|
|
384
|
+
const url = new URL(request.url);
|
|
385
|
+
const op = url.pathname.slice(1);
|
|
386
|
+
// a file's bytes travel as they are: the put's body, the get's answer (its facts in x-gf-answer)
|
|
387
|
+
if (op === 'files.put') {
|
|
388
|
+
const q = url.searchParams;
|
|
389
|
+
// a stream: held to exactly its declared length, or read up to 1 MB when it has none (xgames.fun's put does so)
|
|
390
|
+
const stream = q.get('stream') === '1';
|
|
391
|
+
const length = q.has('length') ? Number(q.get('length')) : null;
|
|
392
|
+
if (stream && length !== null && length > FILES.fileBytes)
|
|
393
|
+
return Response.json({ ok: false, code: 'too-big', why: `A file may be ${FILES.fileBytes / 1048576} MB.` });
|
|
394
|
+
const cap = !stream ? FILES.fileBytes : length !== null ? length : UNSIZED_MAX;
|
|
395
|
+
const body = await readCapped(request, cap);
|
|
396
|
+
if (!body) {
|
|
397
|
+
if (stream && length === null)
|
|
398
|
+
return Response.json({
|
|
399
|
+
ok: false,
|
|
400
|
+
code: 'body',
|
|
401
|
+
why: `A stream over ${UNSIZED_MAX / 1048576} MB needs its length: put(path, stream, {length}). (A string or an ArrayBuffer needs none.)`,
|
|
402
|
+
});
|
|
403
|
+
if (stream)
|
|
404
|
+
return Response.json({
|
|
405
|
+
ok: false,
|
|
406
|
+
code: 'body',
|
|
407
|
+
why: `The stream gave more bytes than its length (${length}), so nothing was stored.`,
|
|
408
|
+
});
|
|
409
|
+
return Response.json({ ok: false, code: 'too-big', why: `A file may be ${FILES.fileBytes / 1048576} MB.` });
|
|
410
|
+
}
|
|
411
|
+
if (stream && length !== null && body.length !== length)
|
|
412
|
+
return Response.json({
|
|
413
|
+
ok: false,
|
|
414
|
+
code: 'body',
|
|
415
|
+
why: `The stream gave fewer bytes than its length (${length}), so nothing was stored.`,
|
|
416
|
+
});
|
|
417
|
+
const opts = { public: q.get('public') === '1', ...(q.get('type') ? { type: q.get('type') } : {}) };
|
|
418
|
+
return Response.json(await files.put(q.get('path') ?? '', body, opts));
|
|
419
|
+
}
|
|
420
|
+
if (op === 'files.get') {
|
|
421
|
+
let args = [];
|
|
422
|
+
try {
|
|
423
|
+
args = await request.json();
|
|
424
|
+
} catch {
|
|
425
|
+
/* no body */
|
|
426
|
+
}
|
|
427
|
+
const a = await files.get(String(args?.[0] ?? ''));
|
|
428
|
+
if (!a.ok) return Response.json(a);
|
|
429
|
+
const { body, ...facts } = a;
|
|
430
|
+
return new Response(body, { headers: { 'x-gf-answer': JSON.stringify(facts) } });
|
|
431
|
+
}
|
|
364
432
|
let args = [];
|
|
365
433
|
try {
|
|
366
434
|
args = await request.json();
|
|
@@ -375,7 +443,38 @@ export function createPlatformStore({
|
|
|
375
443
|
await writing;
|
|
376
444
|
if (data) await write();
|
|
377
445
|
}
|
|
378
|
-
return {
|
|
446
|
+
return {
|
|
447
|
+
slug,
|
|
448
|
+
gameId,
|
|
449
|
+
run,
|
|
450
|
+
pageSubmit,
|
|
451
|
+
handleHost,
|
|
452
|
+
devToken,
|
|
453
|
+
verifyPlayer,
|
|
454
|
+
readToken,
|
|
455
|
+
flush,
|
|
456
|
+
file,
|
|
457
|
+
files,
|
|
458
|
+
setFilesOrigin: o => {
|
|
459
|
+
filesOrigin = o;
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* A request's body, or null when it passes `cap` bytes. Past the cap the rest is read and dropped (up to a bound), so
|
|
466
|
+
* the request ends cleanly: an answer sent while workerd is still sending the body leaves its connection half open.
|
|
467
|
+
*/
|
|
468
|
+
async function readCapped(request, cap, drain = 64 * 1024 * 1024) {
|
|
469
|
+
if (!request.body) return Buffer.alloc(0);
|
|
470
|
+
const chunks = [];
|
|
471
|
+
let size = 0;
|
|
472
|
+
for await (const c of request.body) {
|
|
473
|
+
size += c.byteLength;
|
|
474
|
+
if (size > cap + drain) return null;
|
|
475
|
+
if (size <= cap) chunks.push(Buffer.from(c));
|
|
476
|
+
}
|
|
477
|
+
return size > cap ? null : Buffer.concat(chunks);
|
|
379
478
|
}
|
|
380
479
|
|
|
381
480
|
// ---- the page's side: /platform/v1/* on the dev host -------------------------------------------------------------
|
package/lib/dev/server.mjs
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// env.GF the platform stand-in (./gf-local.js → ./platform.mjs)
|
|
4
4
|
// env.<binding> each Durable Object gamesfun.json declares, with SQLite storage kept in <game>/.gamesfun/dev-server/
|
|
5
5
|
// (so its state survives a restart, as hosted)
|
|
6
|
+
// env.DB the game's database (gamesfun.json → database, under its own binding): a local D1 kept in
|
|
7
|
+
// <game>/.gamesfun/dev/d1/, its migrations applied at each start and reload (./d1.mjs); a migration
|
|
8
|
+
// changed after it was applied here keeps the server down, as xgames.fun refuses that publish
|
|
6
9
|
// env.GF_GAME_ID, GF_SLUG, GF_CONTRACT as hosted (the game id here is dev-<folder>)
|
|
7
10
|
// env.<NAME> the owner's secrets: hosted, set with `xcore secret set`; here, from the game folder's .dev.vars
|
|
8
11
|
// (KEY=value lines; a dot file, so publish never sends it), read again at each reload
|
|
9
12
|
// The dev host sends the page's /game/api/* here as /api/* (HTTP through dispatchFetch; WebSockets through a plain TCP
|
|
10
|
-
// pipe to Miniflare's own port), with every x-gf-* header removed, as
|
|
13
|
+
// pipe to Miniflare's own port), with every x-gf-* header removed, as xgames.fun's dispatch removes them. What is not
|
|
11
14
|
// the same as hosted: no CPU or request limits, and outbound fetch is not limited to backend.json's origins.
|
|
12
15
|
import { existsSync } from 'node:fs';
|
|
13
16
|
import { readFile } from 'node:fs/promises';
|
|
@@ -20,10 +23,12 @@ import { bundleServer } from '../server/bundle.mjs';
|
|
|
20
23
|
import { COMPATIBILITY_DATE, SERVER_FORMAT } from '../server/contract.mjs';
|
|
21
24
|
import { loadMiniflare } from './tools.mjs';
|
|
22
25
|
import { secretNameProblem } from '../server/secrets.mjs';
|
|
26
|
+
import { applyMigrations, d1Options } from './d1.mjs';
|
|
27
|
+
import { stateFolder } from '../state-folder.mjs';
|
|
23
28
|
|
|
24
29
|
const GF_LOCAL = fileURLToPath(new URL('./gf-local.js', import.meta.url));
|
|
25
30
|
|
|
26
|
-
/** Miniflare's options (v4) for one game's bundle: the bindings
|
|
31
|
+
/** Miniflare's options (v4) for one game's bundle: the bindings xgames.fun's upload gives it, the same runtime. */
|
|
27
32
|
export function workerOptions({
|
|
28
33
|
code,
|
|
29
34
|
objects,
|
|
@@ -33,11 +38,14 @@ export function workerOptions({
|
|
|
33
38
|
gfSource,
|
|
34
39
|
wasm = [],
|
|
35
40
|
secrets = {},
|
|
41
|
+
database = null,
|
|
42
|
+
dir = null,
|
|
36
43
|
}) {
|
|
37
44
|
return {
|
|
38
45
|
host: '127.0.0.1',
|
|
39
46
|
port: 0,
|
|
40
47
|
...(persist ? { durableObjectsPersist: persist } : {}),
|
|
48
|
+
...(database?.has ? { d1Persist: d1Options(dir, database.binding).d1Persist } : {}),
|
|
41
49
|
workers: [
|
|
42
50
|
{
|
|
43
51
|
name: 'game',
|
|
@@ -48,6 +56,7 @@ export function workerOptions({
|
|
|
48
56
|
compatibilityDate: COMPATIBILITY_DATE,
|
|
49
57
|
compatibilityFlags: [...compatibilityFlags],
|
|
50
58
|
durableObjects: Object.fromEntries(objects.map(o => [o.binding, { className: o.class, useSQLite: true }])),
|
|
59
|
+
...(database?.has ? { d1Databases: d1Options(dir, database.binding).d1Databases } : {}),
|
|
51
60
|
bindings: { ...secrets, GF_GAME_ID: store.gameId, GF_SLUG: store.slug, GF_CONTRACT: SERVER_FORMAT },
|
|
52
61
|
serviceBindings: { GF: { name: 'gf-local', entrypoint: 'GameServices' } },
|
|
53
62
|
},
|
|
@@ -67,7 +76,7 @@ const HOP = /^(host|connection|keep-alive|transfer-encoding|upgrade|x-gf-.*)$/i;
|
|
|
67
76
|
export const DEV_VARS = '.dev.vars';
|
|
68
77
|
|
|
69
78
|
/**
|
|
70
|
-
* The game folder's .dev.vars as the server's secrets: {secrets: {NAME: value}, names, warnings}. A name
|
|
79
|
+
* The game folder's .dev.vars as the server's secrets: {secrets: {NAME: value}, names, warnings}. A name xgames.fun would
|
|
71
80
|
* refuse (lowercase, GF_*, a room's binding) is left out with a warning, so the code meets the same rules here.
|
|
72
81
|
*/
|
|
73
82
|
export async function readDevVars(dir, objects = []) {
|
|
@@ -104,13 +113,17 @@ export async function startLocalServer({
|
|
|
104
113
|
}) {
|
|
105
114
|
const { Miniflare } = await loadMiniflare({ log, env });
|
|
106
115
|
const gfSource = await readFile(GF_LOCAL, 'utf8');
|
|
116
|
+
await stateFolder(dir);
|
|
107
117
|
let mf = null,
|
|
108
118
|
url = null,
|
|
119
|
+
closed = false, // close() was called: no start after it makes a Miniflare again
|
|
109
120
|
chain = Promise.resolve();
|
|
110
121
|
let state = { running: false, error: null, problems: [], warnings: [], objects: [] };
|
|
111
122
|
|
|
112
123
|
async function start() {
|
|
124
|
+
if (closed) return state;
|
|
113
125
|
const b = await bundleServer(dir, { minify: false });
|
|
126
|
+
if (closed) return state;
|
|
114
127
|
if (!b.has) {
|
|
115
128
|
state = { running: false, error: 'this game has no server/', problems: [], warnings: [], objects: [] };
|
|
116
129
|
return state;
|
|
@@ -123,7 +136,10 @@ export async function startLocalServer({
|
|
|
123
136
|
),
|
|
124
137
|
];
|
|
125
138
|
const objects = b.objects.map(o => o.class);
|
|
126
|
-
const vars = await readDevVars(dir,
|
|
139
|
+
const vars = await readDevVars(dir, [
|
|
140
|
+
...b.objects,
|
|
141
|
+
...(b.database?.has ? [{ class: 'the database', binding: b.database.binding }] : []),
|
|
142
|
+
]);
|
|
127
143
|
warnings.push(...vars.warnings);
|
|
128
144
|
if (b.problems.length || !b.code) {
|
|
129
145
|
if (mf) await mf.dispose().catch(() => {});
|
|
@@ -147,12 +163,44 @@ export async function startLocalServer({
|
|
|
147
163
|
store,
|
|
148
164
|
gfSource,
|
|
149
165
|
secrets: vars.secrets,
|
|
166
|
+
database: b.database,
|
|
167
|
+
dir,
|
|
150
168
|
});
|
|
151
169
|
try {
|
|
152
170
|
if (mf) await mf.setOptions(opts);
|
|
153
171
|
else mf = new Miniflare(opts);
|
|
154
172
|
url = await mf.ready;
|
|
155
|
-
|
|
173
|
+
// the game's database: the migrations not applied here yet, in order; a refused one keeps the server down
|
|
174
|
+
let migrated = [];
|
|
175
|
+
if (b.database?.has) {
|
|
176
|
+
const m = await applyMigrations(dir, await mf.getD1Database(b.database.binding), b.database.migrations);
|
|
177
|
+
if (!m.ok) {
|
|
178
|
+
await mf.dispose().catch(() => {});
|
|
179
|
+
mf = url = null;
|
|
180
|
+
state = {
|
|
181
|
+
running: false,
|
|
182
|
+
error: "the game's database did not take its migrations",
|
|
183
|
+
problems: [`${m.why}.`],
|
|
184
|
+
warnings,
|
|
185
|
+
objects,
|
|
186
|
+
inputs: b.inputs,
|
|
187
|
+
migration: { code: m.code, name: m.name },
|
|
188
|
+
};
|
|
189
|
+
return state;
|
|
190
|
+
}
|
|
191
|
+
migrated = m.applied;
|
|
192
|
+
}
|
|
193
|
+
state = {
|
|
194
|
+
running: true,
|
|
195
|
+
error: null,
|
|
196
|
+
problems: [],
|
|
197
|
+
warnings,
|
|
198
|
+
objects,
|
|
199
|
+
secrets: vars.names,
|
|
200
|
+
inputs: b.inputs,
|
|
201
|
+
...(b.database?.has ? { database: { binding: b.database.binding, migrated } } : {}),
|
|
202
|
+
...(b.stock ? { stock: true } : {}),
|
|
203
|
+
};
|
|
156
204
|
} catch (e) {
|
|
157
205
|
if (mf) await mf.dispose().catch(() => {});
|
|
158
206
|
mf = url = null;
|
|
@@ -242,6 +290,7 @@ export async function startLocalServer({
|
|
|
242
290
|
},
|
|
243
291
|
reload: () => queue(start),
|
|
244
292
|
async close() {
|
|
293
|
+
closed = true;
|
|
245
294
|
await chain;
|
|
246
295
|
if (mf) await mf.dispose().catch(() => {});
|
|
247
296
|
mf = null;
|
package/lib/dev/tools.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// server/, into the folder of this package's state (../config-dir.mjs → state/deps/server-tools-<miniflare version>).
|
|
4
4
|
// GAMESFUN_SERVER_DEPS=<folder> uses one installed elsewhere (a folder whose node_modules has miniflare).
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { userConfigDir } from '../config-dir.mjs';
|
|
10
11
|
|
|
@@ -35,30 +36,82 @@ function npmCli() {
|
|
|
35
36
|
|
|
36
37
|
const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
/** Is process `pid` running (on this machine)? EPERM: it is, as another user's. */
|
|
40
|
+
const alive = pid => {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return e.code === 'EPERM';
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The install lock, <tools folder>/.installing, is in one of three states:
|
|
51
|
+
* free no folder: take it (mkdir, which only one process wins), and write `<pid> <host>` in its pid file.
|
|
52
|
+
* held the folder, by an xcore still installing: wait for it (up to waitMs), then say another xcore is installing.
|
|
53
|
+
* stale the folder, left by an install that was stopped (Ctrl-C kills xcore before it can remove it): taken over at
|
|
54
|
+
* once. Stale when its pid file names a process on this machine that is gone, or (no pid file: an xcore before
|
|
55
|
+
* this one wrote none; or a pid on another machine, or one reused since) when it is older than twice waitMs.
|
|
56
|
+
*/
|
|
57
|
+
function lockState(lock, waitMs) {
|
|
58
|
+
let age;
|
|
59
|
+
try {
|
|
60
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
61
|
+
} catch {
|
|
62
|
+
return 'free';
|
|
63
|
+
}
|
|
64
|
+
let holder = null;
|
|
65
|
+
try {
|
|
66
|
+
const [pid, host] = readFileSync(path.join(lock, 'pid'), 'utf8').trim().split(/\s+/);
|
|
67
|
+
if (Number.isSafeInteger(Number(pid)) && Number(pid) > 0) holder = { pid: Number(pid), host };
|
|
68
|
+
} catch {
|
|
69
|
+
/* no pid file */
|
|
70
|
+
}
|
|
71
|
+
if (holder && holder.host === os.hostname() && !alive(holder.pid)) return 'stale';
|
|
72
|
+
return age > waitMs * 2 ? 'stale' : 'held';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Take the install lock in `dir` (one install at a time), waiting while another xcore holds it. → release(), or null
|
|
77
|
+
* when `done()` turned true while waiting (the other xcore installed the tools). Once held, any .stage-<pid> folder
|
|
78
|
+
* there is a stopped install's, and goes.
|
|
79
|
+
*/
|
|
80
|
+
export function takeInstallLock(dir, { waitMs = WAIT_MS, done = () => false } = {}) {
|
|
44
81
|
const lock = path.join(dir, '.installing');
|
|
45
82
|
const started = Date.now();
|
|
46
83
|
for (;;) {
|
|
47
84
|
try {
|
|
48
85
|
mkdirSync(lock);
|
|
49
86
|
break;
|
|
50
|
-
} catch {
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
else if (
|
|
58
|
-
throw new Error(`another xcore is still installing the server tools in ${dir}`);
|
|
59
|
-
else pause(500);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e.code !== 'EEXIST') throw e;
|
|
89
|
+
if (done()) return null;
|
|
90
|
+
const state = lockState(lock, waitMs);
|
|
91
|
+
if (state === 'stale') rmSync(lock, { recursive: true, force: true });
|
|
92
|
+
else if (state === 'held' && Date.now() - started > waitMs)
|
|
93
|
+
throw new Error(`another xcore is still installing the server tools in ${dir}`, { cause: e });
|
|
94
|
+
else if (state === 'held') pause(500);
|
|
60
95
|
}
|
|
61
96
|
}
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(path.join(lock, 'pid'), `${process.pid} ${os.hostname()}\n`);
|
|
99
|
+
} catch {
|
|
100
|
+
/* without it, the lock goes stale by its age */
|
|
101
|
+
}
|
|
102
|
+
for (const name of readdirSync(dir))
|
|
103
|
+
if (/^\.stage-\d+$/.test(name)) rmSync(path.join(dir, name), { recursive: true, force: true });
|
|
104
|
+
return () => rmSync(lock, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Install the tools when they are not there yet (one process at a time; whole or not at all). → the folder */
|
|
108
|
+
export function ensureTools({ log = m => console.error(m), env = process.env } = {}) {
|
|
109
|
+
const dir = toolsFolder(env);
|
|
110
|
+
if (ready(dir)) return dir;
|
|
111
|
+
if (env.GAMESFUN_SERVER_DEPS) throw new Error(`GAMESFUN_SERVER_DEPS (${dir}) has no node_modules/miniflare`);
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
const release = takeInstallLock(dir, { done: () => ready(dir) });
|
|
114
|
+
if (!release) return dir;
|
|
62
115
|
try {
|
|
63
116
|
if (ready(dir)) return dir;
|
|
64
117
|
const cli = npmCli();
|
|
@@ -90,7 +143,7 @@ export function ensureTools({ log = m => console.error(m), env = process.env } =
|
|
|
90
143
|
rmSync(stage, { recursive: true, force: true });
|
|
91
144
|
return dir;
|
|
92
145
|
} finally {
|
|
93
|
-
|
|
146
|
+
release();
|
|
94
147
|
}
|
|
95
148
|
}
|
|
96
149
|
|