@joystack/cli 0.1.45 → 0.1.46
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/CHANGELOG.md +23 -0
- package/lib/dev.mjs +268 -113
- package/lib/freeze.mjs +81 -25
- package/lib/live-versions.mjs +212 -0
- package/lib/static-server.mjs +208 -110
- package/package.json +1 -1
- package/lib/room-reload.mjs +0 -168
package/lib/static-server.mjs
CHANGED
|
@@ -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;
|
|
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 {
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
|
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) => {
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>' })[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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
323
|
-
//
|
|
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
|
|
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
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 (
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
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('
|
|
766
|
+
reload.on('status', pushStatus);
|
|
750
767
|
res.on('close', () => {
|
|
751
768
|
clearInterval(keepalive);
|
|
752
769
|
reload.off('reload', push);
|
|
753
|
-
reload.off('
|
|
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
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
currentSkeleton =
|
|
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
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
package/lib/room-reload.mjs
DELETED
|
@@ -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
|
-
}
|