@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
@@ -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 games.fun's shape: {ok: true, …} or {ok: false, code, why}. A refusal is an answer, never a throw,
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 games.fun signs a player in on this machine, so the dev host hands out its own tokens
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; a games.fun token (EdDSA,
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
- const load = async () => {
55
- if (data) return data;
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: games.fun's answer shape (no name: hosted it has none), plus `dev: true`. */
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); a games.fun token is checked when the game is hosted.',
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'} (games.fun's GF.scores.setup).
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, games.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)}`,
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 op = new URL(request.url).pathname.slice(1);
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 { slug, gameId, run, pageSubmit, handleHost, devToken, verifyPlayer, readToken, flush, file };
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 -------------------------------------------------------------
@@ -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 games.fun's dispatch removes them. What is not
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 games.fun's upload gives it, the same runtime. */
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 games.fun would
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, b.objects);
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
- state = { running: true, error: null, problems: [], warnings, objects, secrets: vars.names, inputs: b.inputs };
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
- /** Install the tools when they are not there yet (one process at a time; whole or not at all). → the folder */
39
- export function ensureTools({ log = m => console.error(m), env = process.env } = {}) {
40
- const dir = toolsFolder(env);
41
- if (ready(dir)) return dir;
42
- if (env.GAMESFUN_SERVER_DEPS) throw new Error(`GAMESFUN_SERVER_DEPS (${dir}) has no node_modules/miniflare`);
43
- mkdirSync(dir, { recursive: true });
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 (ready(dir)) return dir;
52
- let age = 0;
53
- try {
54
- age = Date.now() - statSync(lock).mtimeMs;
55
- } catch {}
56
- if (age > WAIT_MS * 2) rmSync(lock, { recursive: true, force: true });
57
- else if (Date.now() - started > WAIT_MS)
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
- rmSync(lock, { recursive: true, force: true });
146
+ release();
94
147
  }
95
148
  }
96
149