@joystack/cli 0.1.45 → 0.1.47

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.
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // /games/<slug>/… → the game folder
5
5
  // /shared/… → the engine bundle's shared/ tree
6
- // /__reload → SSE; pings open tabs when a watched file changes
6
+ // /__reload → SSE; tells open tabs the version they run on is replaced
7
+ // /__joystack/dev → the versions' state; reload, and auto-reload on or off
7
8
  //
8
9
  // Mounts are ORDERED — first prefix match wins. That ordering is how
9
10
  // overrides/shared/… will shadow the engine bundle (Slice 2): the game's
@@ -12,7 +13,7 @@ import http from 'node:http';
12
13
  import { connect as connectTcp } from 'node:net';
13
14
  import { randomBytes, timingSafeEqual } from 'node:crypto';
14
15
  import { EventEmitter } from 'node:events';
15
- import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
16
+ import { readFileSync, statSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
16
17
  import { extname, join, normalize, sep } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { gunzipSync } from 'node:zlib';
@@ -48,19 +49,38 @@ const REPLAY_URL_RE = new RegExp(`^/games/[^/]+/(?:${STATE_DIRS_RE})/replays/([a
48
49
  // one of them carries a dash and none of them is a word.
49
50
  const LATEST_ALIAS = 'last';
50
51
 
51
- // The page's half of /__reload. Two events arrive on it: `reload`, a watched
52
- // file changed, and `room`, the multiplayer room was rebuilt and restarted. A
53
- // page under `joystack dev --no-reload` ignores `reload`. Every page honours
54
- // `room`: a restarted room may speak a different wire schema, and a page still
55
- // on the old one drops every snapshot and sits on a frozen world.
56
- const reloadClient = (events) => `<script>
52
+ // The page's half of /__reload, under `joystack dev` (live-versions.mjs). Every
53
+ // page the server serves belongs to one version of the game, and leaves when
54
+ // that version stops being the one served:
55
+ //
56
+ // game a game page. It reloads as soon as an update starts, so no game
57
+ // runs while files are replaced; the server answers it with the
58
+ // "Updating…" page, then the game.
59
+ // status the "Updating…" or error page. It reloads when a version is ready.
60
+ // other any other page (the Assets page). It reloads once the new version
61
+ // is served.
62
+ //
63
+ // The stream sends `reload` after every switch, and the status (JSON) on
64
+ // connecting and whenever it changes. A page whose version is not the one in
65
+ // the status reloads at once: it was served just before a switch.
66
+ const reloadClient = (kind, version, token) => `<script>
57
67
  (() => {
58
- const on = ${JSON.stringify(events)};
68
+ const kind = ${JSON.stringify(kind)};
69
+ const version = ${JSON.stringify(version)};
59
70
  let alive = false;
71
+ let leaving = false;
72
+ const leave = () => { if (!leaving) { leaving = true; location.reload(); } };
60
73
  function connect() {
61
- const es = new EventSource('/__reload');
74
+ const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
62
75
  es.onopen = () => { alive = true; };
63
- es.onmessage = (e) => { if (on.includes(e.data)) location.reload(); };
76
+ es.onmessage = (e) => {
77
+ if (e.data === 'reload') return leave();
78
+ let s;
79
+ try { s = JSON.parse(e.data); } catch { return; }
80
+ if (kind === 'game' && (s.state !== 'ready' || s.version !== version)) return leave();
81
+ if (kind === 'status' && s.state === 'ready') return leave();
82
+ if (kind === 'status') window.dispatchEvent(new CustomEvent('joystack-status', { detail: s }));
83
+ };
64
84
  es.onerror = () => {
65
85
  es.close();
66
86
  setTimeout(connect, alive ? 200 : 1000);
@@ -71,15 +91,50 @@ const reloadClient = (events) => `<script>
71
91
  })();
72
92
  </script>`;
73
93
 
74
- // Models and sounds are watched as well as code and images: re-exporting a .glb
75
- // or a .wav from an editor is an edit to the game, and the page showing it (the
76
- // game or the Assets page) should reload just as it does for a source file.
77
- const WATCH_EXTS = new Set([
78
- '.html', '.css', '.js', '.mjs', '.json', '.svg',
79
- '.png', '.jpg', '.jpeg', '.gif', '.webp',
80
- '.glb', '.gltf', '.bin',
81
- '.wav', '.mp3', '.ogg', '.m4a', '.flac',
82
- ]);
94
+ // The one line of a failed build a person needs: the first error it reports,
95
+ // with the game's paths written from the game folder and the build's own
96
+ // location left off. The whole error stays in the terminal and in
97
+ // GET /__joystack/dev, where an agent reads it.
98
+ export function errorSummary(text, gameDir = null) {
99
+ const lines = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)
100
+ // Node's own warnings ride along on the build's output.
101
+ .filter((l) => !/^\(node:\d+\)|^\(Use `node --trace-warnings|^Reparsing as ES module|^To eliminate this warning|package\.json\.$/.test(l));
102
+ const first = lines.find((l) => l.startsWith('✘')) ?? lines.find((l) => /error|cannot|not defined|unexpected/i.test(l)) ?? lines[0] ?? '';
103
+ let line = first.replace(/^✘\s*/, '').replace(/^\S+:\d+(?::\d+)?\s+—\s+/, '').replace(/^Expansion threw:\s*/, '');
104
+ if (gameDir) {
105
+ // Any absolute path into the game folder, however it is spelled: with
106
+ // either separator, a drive letter, /private on macOS, or a Windows short
107
+ // name for a folder above the game's.
108
+ const name = String(gameDir).split(/[\\/]/).filter(Boolean).at(-1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
109
+ line = line.replace(new RegExp(`(?:file:/+)?(?:[A-Za-z]:)?[\\\\/][^\\s'"]*?[\\\\/]${name}[\\\\/]([^\\s'"]*)`, 'g'), (_, rest) => rest.replace(/\\/g, '/'));
110
+ }
111
+ const missing = /^Cannot find module '([^']+)' imported from (\S+)$/.exec(line);
112
+ if (missing) return `${missing[1]} does not exist, but ${missing[2]} imports it.`;
113
+ return line;
114
+ }
115
+
116
+ // What a game page is answered with while no version can be shown: an update
117
+ // in progress, or the error from the last one.
118
+ function statusPage(status, gameDir) {
119
+ const failed = status.state === 'failed';
120
+ const esc = (t) => String(t).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
121
+ return `<!doctype html>
122
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
123
+ <title>${failed ? 'The game has an error' : 'Updating…'}</title>
124
+ <style>
125
+ html,body{margin:0;height:100%;background:#0b0b10;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}
126
+ main{min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;box-sizing:border-box;text-align:center}
127
+ .spin{width:28px;height:28px;border:3px solid #374151;border-top-color:#e5e7eb;border-radius:50%;animation:s .8s linear infinite}
128
+ @keyframes s{to{transform:rotate(360deg)}}
129
+ h1{font-size:16px;font-weight:600;margin:0}
130
+ p{margin:0;max-width:34em;color:#9ca3af;overflow-wrap:anywhere}
131
+ </style></head>
132
+ <body><main data-joystack-status="${failed ? 'failed' : 'updating'}">
133
+ ${failed
134
+ ? `<h1>The game has an error</h1><p>${esc(errorSummary(status.error, gameDir))}</p>`
135
+ : '<div class="spin"></div><h1>Updating…</h1>'}
136
+ </main></body></html>`;
137
+ }
83
138
 
84
139
  // The toolbox → agent report channel (see handleAgentInbox below). The path is
85
140
  // a contract with the toolbox shell's ctx.toAgent(); the cap keeps a runaway
@@ -87,6 +142,8 @@ const WATCH_EXTS = new Set([
87
142
  // screenshot, so its cap is larger; anyone else (a LAN device, a --share
88
143
  // tester) sends small JSON summaries only.
89
144
  export const AGENT_INBOX_PATH = '/__joystack/agent-inbox';
145
+ // The versions' control lane (handleDevControl).
146
+ export const DEV_CONTROL_PATH = '/__joystack/dev';
90
147
  const AGENT_INBOX_MAX_BYTES = 2 * 1024 * 1024;
91
148
  const AGENT_INBOX_LOCAL_MAX_BYTES = 8 * 1024 * 1024;
92
149
  // A page can post in a loop. The agent is woken at most this often: a request
@@ -250,51 +307,16 @@ const MIME = {
250
307
  '.bin': 'application/octet-stream',
251
308
  };
252
309
 
253
- function collectMtimes(roots) {
254
- const out = new Map();
255
- for (const root of roots) {
256
- if (!existsSync(root)) continue;
257
- const walk = (dir) => {
258
- let names;
259
- try {
260
- names = readdirSync(dir);
261
- } catch {
262
- return;
263
- }
264
- for (const name of names) {
265
- if (name.startsWith('.') || name === 'node_modules') continue;
266
- const p = join(dir, name);
267
- let st;
268
- try {
269
- st = statSync(p);
270
- } catch {
271
- continue;
272
- }
273
- if (st.isDirectory()) walk(p);
274
- else if (WATCH_EXTS.has(extname(name).toLowerCase())) out.set(p, st.mtimeMs);
275
- }
276
- };
277
- walk(root);
278
- }
279
- return out;
280
- }
281
-
282
- function mtimesChanged(a, b) {
283
- if (a.size !== b.size) return true;
284
- for (const [k, v] of a) if (b.get(k) !== v) return true;
285
- return false;
286
- }
287
-
288
310
  export function createStaticServer({
289
311
  slug,
290
312
  mounts,
291
313
  projectDir = null,
292
- watchDirs = [],
293
- injectReload = true,
294
- // The build every session is stamped with, when the game served is a copy
295
- // frozen at startup (`joystack dev --no-reload`); null to hash projectDir.
314
+ // The build every session is stamped with, or a function returning it, when
315
+ // the game served is a copy (a version, see setGame); null to hash projectDir.
296
316
  fixedBuild = null,
297
- watchIntervalMs = 400,
317
+ // `joystack dev`'s versions (live-versions.mjs): { status(), reload(),
318
+ // setAuto(on) }. Null serves the mounts as they are, with no reload.
319
+ versions = null,
298
320
  identity,
299
321
  log = console.log,
300
322
  // A framework-v2 game's lowered skeleton — injected into the entry HTML as an
@@ -319,19 +341,17 @@ export function createStaticServer({
319
341
  // The tunnel hostname `joystack dev --share` opened to this server, once it is
320
342
  // up (setShareHost). See trustedHost.
321
343
  let shareHost = null;
322
- // The injected skeleton is LIVE, not frozen at startup: a v2 hot-reload
323
- // rebuilds the room with a fresh skeleton, and `setSkeleton` below pushes that
324
- // same skeleton here so the next page load injects it. Without this the client
325
- // keeps injecting the startup skeleton after an edit while the room ships the
326
- // new one, and assembleGraph throws "kind X re-ran N ... but the skeleton has
327
- // M" on the next reload.
344
+ // The mounts, the skeleton and the build stamp are one version of the game,
345
+ // replaced together by setGame.
328
346
  let currentSkeleton = skeleton;
347
+ let currentVersion = 0;
329
348
 
330
349
  // Normalize mounts: url ends with '/', dir has no trailing separator.
331
- const table = mounts.map(({ url, dir }) => ({
350
+ const normalized = (list) => list.map(({ url, dir }) => ({
332
351
  url: url.endsWith('/') ? url : url + '/',
333
352
  dir: normalize(dir),
334
353
  }));
354
+ let table = normalized(mounts);
335
355
 
336
356
  // URL path → filesystem path. Mounts are tried in order and FALL THROUGH:
337
357
  // the first mount that actually contains the file wins. That ordering is
@@ -366,35 +386,25 @@ export function createStaticServer({
366
386
 
367
387
  const reload = new EventEmitter();
368
388
  reload.setMaxListeners(0);
369
- let watchTimer = null;
370
- // The build a new session is stamped with. Hashing the game reads every
371
- // file and runs git, so it is done once and kept until the watcher sees a
372
- // file change; without a watcher nothing would say when it went stale, so it
373
- // is computed for each session instead.
374
- let buildCache = null;
375
- const currentBuild = () => {
376
- if (fixedBuild) return { ...fixedBuild, engine: engineVersion };
377
- if (buildCache) return buildCache;
378
- const build = { ...gameBuild(projectDir), engine: engineVersion };
379
- if (watchTimer) buildCache = build;
380
- return build;
381
- };
382
- if (watchDirs.length) {
383
- let snapshot = collectMtimes(watchDirs);
384
- watchTimer = setInterval(() => {
385
- const next = collectMtimes(watchDirs);
386
- if (mtimesChanged(snapshot, next)) {
387
- snapshot = next;
388
- buildCache = null;
389
- reload.emit('reload');
390
- }
391
- }, watchIntervalMs);
392
- watchTimer.unref();
393
- }
389
+ // The build a new session is stamped with: the version's, or, serving the
390
+ // folder itself, a hash of it taken for each session, since nothing says
391
+ // when a kept one would go stale.
392
+ const currentBuild = () => ({ ...((typeof fixedBuild === 'function' ? fixedBuild() : fixedBuild) ?? gameBuild(projectDir)), engine: engineVersion });
393
+
394
+ // Game pages of each version: those connected to /__reload, and those served
395
+ // and not connected yet, by the token their reload client carries. A switch
396
+ // waits until no page of the previous version is left (pagesGone).
397
+ const gamePages = new Map(); // token → { version, connected, servedAt }
398
+ let nextToken = 1;
394
399
 
395
400
  const gameEntry = `/games/${slug}/index.html`;
396
401
 
397
402
  function serveHtml(req, res, fsPath, urlPath, query) {
403
+ const gamePage = /^\/games\/[^/]+\//.test(urlPath);
404
+ const status = versions?.status();
405
+ if (gamePage && status && status.state !== 'ready') {
406
+ return sendBody(res, 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html']);
407
+ }
398
408
  let html = readFileSync(fsPath, 'utf8');
399
409
  html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
400
410
  html = withLegacyImportMap(html);
@@ -434,9 +444,13 @@ export function createStaticServer({
434
444
  ...(shared ? { shareHost } : {}),
435
445
  });
436
446
  }
437
- if (watchDirs.length) {
438
- const events = injectReload ? ['reload', 'room'] : ['room'];
439
- const client = reloadClient(events);
447
+ if (versions) {
448
+ let token = 0;
449
+ if (gamePage) {
450
+ token = nextToken++;
451
+ gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
452
+ }
453
+ const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
440
454
  html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
441
455
  }
442
456
  sendBody(res, 200, html, MIME['.html']);
@@ -734,26 +748,79 @@ export function createStaticServer({
734
748
  return json(200, { stream, deleted: true });
735
749
  }
736
750
 
737
- function handleSse(res) {
751
+ function handleSse(res, query) {
738
752
  res.writeHead(200, {
739
753
  'Content-Type': 'text/event-stream',
740
754
  'Cache-Control': 'no-cache',
741
755
  Connection: 'keep-alive',
742
756
  });
743
757
  res.write(':connected\n\n');
758
+ const page = gamePages.get(Number(query.get('token')));
759
+ if (page) page.connected = true;
744
760
  const push = () => res.write('data: reload\n\n');
745
- const pushRoom = () => res.write('data: room\n\n');
761
+ const pushStatus = (s) => res.write(`data: ${JSON.stringify(s)}\n\n`);
762
+ if (versions) pushStatus(publicStatus());
746
763
  const keepalive = setInterval(() => res.write(':keepalive\n\n'), 10_000);
747
764
  keepalive.unref();
748
765
  reload.on('reload', push);
749
- reload.on('room', pushRoom);
766
+ reload.on('status', pushStatus);
750
767
  res.on('close', () => {
751
768
  clearInterval(keepalive);
752
769
  reload.off('reload', push);
753
- reload.off('room', pushRoom);
770
+ reload.off('status', pushStatus);
771
+ if (page) {
772
+ gamePages.delete(Number(query.get('token')));
773
+ reload.emit('page-gone');
774
+ }
754
775
  });
755
776
  }
756
777
 
778
+ // The versions' state as pages and the control endpoint see it.
779
+ const publicStatus = () => ({ ...versions.status(), version: currentVersion });
780
+
781
+ // GET /__joystack/dev the state (see live-versions.mjs)
782
+ // POST /__joystack/dev/reload make a version of the files as they are
783
+ // POST /__joystack/dev/auto { auto } auto-reload on or off
784
+ //
785
+ // Changing the mode or reloading changes what every viewer of the game
786
+ // gets, so it is taken only from this machine, never from a phone on the
787
+ // network or a --share tester.
788
+ async function handleDevControl(req, res, urlPath) {
789
+ const json = (status, body) => {
790
+ const buf = Buffer.from(JSON.stringify(body));
791
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store' });
792
+ res.end(buf);
793
+ };
794
+ if (urlPath === DEV_CONTROL_PATH) {
795
+ if (req.method !== 'GET') return json(405, { error: 'GET only' });
796
+ if (!sameOrigin(req, { shareHost })) return json(403, { error: 'cross-origin request rejected' });
797
+ return json(200, publicStatus());
798
+ }
799
+ if (req.method !== 'POST') return json(405, { error: 'POST only' });
800
+ if (!sameOrigin(req, { shareHost }) || !fromThisMachine(req)) return json(403, { error: 'only from this machine' });
801
+ if (urlPath === `${DEV_CONTROL_PATH}/reload`) {
802
+ versions.reload();
803
+ return json(202, publicStatus());
804
+ }
805
+ if (urlPath === `${DEV_CONTROL_PATH}/auto`) {
806
+ let body;
807
+ try {
808
+ let text = '';
809
+ for await (const c of req) {
810
+ text += c;
811
+ if (text.length > 1024) return json(413, { error: 'body too large' });
812
+ }
813
+ body = JSON.parse(text);
814
+ } catch {
815
+ return json(400, { error: 'expected { "auto": true } or { "auto": false }' });
816
+ }
817
+ if (typeof body?.auto !== 'boolean') return json(400, { error: 'expected { "auto": true } or { "auto": false }' });
818
+ versions.setAuto(body.auto);
819
+ return json(200, publicStatus());
820
+ }
821
+ return json(404, { error: 'not found' });
822
+ }
823
+
757
824
  // The agent inbox — the toolbox → agent channel. A dev tool in the running
758
825
  // game POSTs an envelope ({ tool, at, device, payload } stamped by the
759
826
  // toolbox shell, plus an optional `message` the person typed and an optional
@@ -1060,7 +1127,10 @@ export function createStaticServer({
1060
1127
  const server = http.createServer((req, res) => {
1061
1128
  const reqUrl = new URL(req.url, 'http://x');
1062
1129
  const urlPath = canonicalDevPath(reqUrl.pathname);
1063
- if (urlPath === '/__reload') return handleSse(res);
1130
+ if (urlPath === '/__reload') return handleSse(res, reqUrl.searchParams);
1131
+ if (versions && (urlPath === DEV_CONTROL_PATH || urlPath.startsWith(`${DEV_CONTROL_PATH}/`))) {
1132
+ return handleDevControl(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1133
+ }
1064
1134
  // Without a projectDir there is nowhere to land a report — the route
1065
1135
  // simply doesn't exist (falls through to 404), same as any unknown path.
1066
1136
  if (urlPath === AGENT_INBOX_PATH && projectDir) return handleAgentInbox(req, res);
@@ -1198,16 +1268,45 @@ export function createStaticServer({
1198
1268
  return server.address()?.port;
1199
1269
  },
1200
1270
  resolveUrl,
1201
- // Swap the skeleton injected into subsequent page loads — the v2 hot-reload
1202
- // path calls this after each room rebuild so the client and room never
1203
- // disagree on the lowered graph.
1204
- setSkeleton(next) {
1205
- currentSkeleton = next ?? null;
1271
+ // Serve another version: its mounts, its skeleton and its build stamp,
1272
+ // all at once. Open pages are told to reload.
1273
+ setGame({ id, mounts: next, skeleton: nextSkeleton = null, build = null }) {
1274
+ table = normalized(next);
1275
+ currentSkeleton = nextSkeleton;
1276
+ fixedBuild = build;
1277
+ currentVersion = id;
1278
+ reload.emit('reload');
1206
1279
  },
1207
- // The multiplayer room was rebuilt and restarted: every open page reloads,
1208
- // so none is left on a wire schema the new room no longer speaks.
1209
- roomRestarted() {
1210
- reload.emit('room');
1280
+ // The versions' state changed (live-versions.mjs onStatus).
1281
+ statusChanged() {
1282
+ if (versions) reload.emit('status', publicStatus());
1283
+ },
1284
+ // Resolves once no game page of a version other than `id` is open, or
1285
+ // after `timeoutMs`. A page served but not connected yet counts for two
1286
+ // seconds: its reload client may still be on its way.
1287
+ async pagesGone(id, { timeoutMs = 5000 } = {}) {
1288
+ const deadline = Date.now() + timeoutMs;
1289
+ for (;;) {
1290
+ const now = Date.now();
1291
+ let open = false;
1292
+ for (const [token, p] of gamePages) {
1293
+ if (p.version === id) continue;
1294
+ if (!p.connected && now - p.servedAt > 2000) gamePages.delete(token);
1295
+ else open = true;
1296
+ }
1297
+ if (!open || now > deadline) return !open;
1298
+ // Woken as a page leaves; the timer is for pages served and never
1299
+ // connected, which no event announces.
1300
+ await new Promise((r) => {
1301
+ const done = () => {
1302
+ clearTimeout(timer);
1303
+ reload.off('page-gone', done);
1304
+ r();
1305
+ };
1306
+ const timer = setTimeout(done, 200);
1307
+ reload.on('page-gone', done);
1308
+ });
1309
+ }
1211
1310
  },
1212
1311
  // Trust the tunnel hostname `joystack dev --share` opened (see trustedHost);
1213
1312
  // null to stop.
@@ -1229,7 +1328,6 @@ export function createStaticServer({
1229
1328
  if (JSON.parse(readFileSync(file, 'utf8')).token === inboxToken) unlinkSync(file);
1230
1329
  } catch { /* never written, or already replaced */ }
1231
1330
  }
1232
- if (watchTimer) clearInterval(watchTimer);
1233
1331
  history?.close();
1234
1332
  server.close();
1235
1333
  server.closeAllConnections?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Joystack game development CLI \u2014 dev server, login, and publishing for standalone Joystack games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,168 +0,0 @@
1
- // Room hot-reload for `joystack dev`.
2
- //
3
- // The room bundle (room-server.mjs) is frozen at build time and partykit runs
4
- // it as-is, so an edit to a room-authoritative file — entity data, an override,
5
- // a server sim, a manifest — reaches the client (page reload) but not the room
6
- // unless something rebuilds it. This module is that something: a poll watcher
7
- // over the bundle's actual inputs plus the room-shaped game trees, and a
8
- // reloader that rebuilds and restarts the partykit child on change.
9
- //
10
- // Restarting the child drops the room's in-memory world on purpose: the state
11
- // was seeded from pre-edit files, and the page reload the static watcher
12
- // already fires makes the next join reseed from the fresh ones. A rebuild that
13
- // FAILS must never take the working room down with it — the creator gets the
14
- // old room and a loud message, not a dead stack.
15
- import { readdirSync, statSync } from 'node:fs';
16
- import { extname, join } from 'node:path';
17
-
18
- // Extensions that can be a room input when watching a whole dir. Explicit
19
- // files (bundle metafile inputs) are watched regardless of extension.
20
- const ROOM_EXTS = new Set(['.js', '.mjs', '.json', '.wasm']);
21
-
22
- function snapshot(files, dirs) {
23
- const out = new Map();
24
- for (const f of files) {
25
- try {
26
- out.set(f, statSync(f).mtimeMs);
27
- } catch {
28
- out.set(f, -1); // a vanished input is a change too
29
- }
30
- }
31
- const walk = (dir) => {
32
- let names;
33
- try {
34
- names = readdirSync(dir);
35
- } catch {
36
- return; // the tree may not exist yet — its first file appearing is a change
37
- }
38
- for (const name of names) {
39
- if (name.startsWith('.') || name === 'node_modules') continue;
40
- const p = join(dir, name);
41
- let st;
42
- try {
43
- st = statSync(p);
44
- } catch {
45
- continue;
46
- }
47
- if (st.isDirectory()) walk(p);
48
- else if (ROOM_EXTS.has(extname(name).toLowerCase())) out.set(p, st.mtimeMs);
49
- }
50
- };
51
- for (const d of dirs) walk(d);
52
- return out;
53
- }
54
-
55
- function differs(a, b) {
56
- if (a.size !== b.size) return true;
57
- for (const [k, v] of a) if (b.get(k) !== v) return true;
58
- return false;
59
- }
60
-
61
- export function createFileWatcher({ files = [], dirs = [], intervalMs = 400, onChange = null }) {
62
- // Two independent baselines: the explicit file list (which setFiles swaps
63
- // between rebuilds) and the dir walk (which setFiles must never touch). One
64
- // merged map couldn't tell a dropped input apart from a dir-walk entry.
65
- let watchedFiles = [...files];
66
- const watchedDirs = [...dirs];
67
- let fileSnap = snapshot(watchedFiles, []);
68
- let dirSnap = snapshot([], watchedDirs);
69
- const oneShots = [];
70
- const timer = setInterval(() => {
71
- const nextFiles = snapshot(watchedFiles, []);
72
- const nextDirs = snapshot([], watchedDirs);
73
- if (differs(fileSnap, nextFiles) || differs(dirSnap, nextDirs)) {
74
- fileSnap = nextFiles;
75
- dirSnap = nextDirs;
76
- while (oneShots.length) oneShots.pop()();
77
- // Fire-and-forget on purpose, but a rejection out of the handler must
78
- // not become an unhandled rejection that takes the dev stack down.
79
- try {
80
- Promise.resolve(onChange?.()).catch(() => {});
81
- } catch {
82
- /* handler threw synchronously — the handler owns its own reporting */
83
- }
84
- }
85
- }, intervalMs);
86
- timer.unref();
87
- return {
88
- setFiles(next) {
89
- // Swap the file LIST without rebaselining mtimes: an edit that landed
90
- // after the last poll (mid-rebuild, before this call) must still diff
91
- // on the next tick. Only genuinely new paths get a fresh stat; entries
92
- // already tracked keep their last-observed mtime.
93
- const merged = new Map();
94
- for (const f of next) {
95
- if (fileSnap.has(f)) merged.set(f, fileSnap.get(f));
96
- else merged.set(f, snapshot([f], []).get(f));
97
- }
98
- watchedFiles = [...next];
99
- fileSnap = merged;
100
- },
101
- onNextChange(fn) {
102
- oneShots.push(fn);
103
- },
104
- close() {
105
- clearInterval(timer);
106
- },
107
- };
108
- }
109
-
110
- export function createRoomReloader({ build, spawnChild, killChild, log, onRebuilt = null }) {
111
- let current = null; // { cwd, child }
112
- let running = false;
113
- let dirty = false;
114
- let stopped = false;
115
-
116
- async function onChange() {
117
- if (stopped) return;
118
- if (running) {
119
- dirty = true;
120
- return;
121
- }
122
- running = true;
123
- try {
124
- do {
125
- dirty = false;
126
- let built;
127
- try {
128
- built = await build();
129
- } catch (err) {
130
- log(`✗ room rebuild failed — the running room still serves your PREVIOUS code:\n${err.message}`);
131
- continue;
132
- }
133
- // stop() may race any await in here (`joystack test` tearing the stack
134
- // down, SIGINT): once stopped, spawning would leak a detached room
135
- // child forever. The check must repeat AFTER the kill too — killChild
136
- // waits for the child's exit and can take seconds (SIGTERM, then the
137
- // SIGKILL escalation), which is the widest window a stop() can land in.
138
- if (stopped) return;
139
- try {
140
- await killChild(current.child);
141
- if (stopped) return;
142
- const child = spawnChild(built.cwd);
143
- current = { cwd: built.cwd, child };
144
- } catch (err) {
145
- log(`✗ room restart failed — fix the cause, then edit any room file to retry:\n${err.message}`);
146
- continue;
147
- }
148
- onRebuilt?.(built);
149
- log('→ room server rebuilt and restarted (room state reseeds on the next join)');
150
- } while (dirty && !stopped);
151
- } finally {
152
- running = false;
153
- }
154
- }
155
-
156
- return {
157
- attach(cwd, child) {
158
- current = { cwd, child };
159
- },
160
- onChange,
161
- stop() {
162
- stopped = true;
163
- },
164
- get current() {
165
- return current;
166
- },
167
- };
168
- }