@joystack/cli 0.1.53 → 0.1.55

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.
@@ -0,0 +1,320 @@
1
+ // `joystack shot <script>`: a short Playwright script of the agent's own, run
2
+ // against the running game, with the parts that go wrong when every script
3
+ // does them for itself done once here.
4
+ //
5
+ // joystack shot .joystack/look.mjs
6
+ //
7
+ // The script exports one function. It is handed a page already open on the
8
+ // game, at a phone's size, under a player name of its own, with the game
9
+ // started; it drives the game to the moment it wants and calls
10
+ // `shot(name)` for each picture:
11
+ //
12
+ // export default async ({ page, game, shot }) => {
13
+ // await page.selectOption('#jump', '6');
14
+ // await game.waitFor((w) => w.query('boss').length > 0);
15
+ // await shot('boss');
16
+ // };
17
+ //
18
+ // What the command owns, so a script never does it:
19
+ // - the check before anything starts: the file parses, exports a function, and
20
+ // neither launches a browser nor opens an address of its own (check.mjs);
21
+ // - the dev stack: the one running for the game (in the maker, the draft's), or
22
+ // one started for the length of the command;
23
+ // - the moment the page opens: only once the dev server has finished updating
24
+ // after an edit (ready.mjs), and the run is stopped, saying so, when the game
25
+ // updates under it;
26
+ // - the browser: one, with the same flags every time, closed afterwards, and
27
+ // one script at a time on a computer (lock.mjs);
28
+ // - time: every step of the page waits at most a few seconds, the whole script
29
+ // a minute unless --timeout says otherwise.
30
+ //
31
+ // The script runs in a child process (run.mjs) with the same preloads a
32
+ // browser smoke gets, so it can import the joystack doors and a stuck script
33
+ // is stopped by ending that process.
34
+
35
+ import { spawn } from 'node:child_process';
36
+ import { resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { findProject } from '../project.mjs';
39
+ import { stackOrigin, stateFolderIn } from '../assets-sheet.mjs';
40
+ import { makerDevPort, makerDraft } from '../maker.mjs';
41
+ import { joystackTestPreload, SMOKE_PRELOAD } from '../test-cmd.mjs';
42
+ import { checkScript } from './check.mjs';
43
+ import { devState, untilReady } from './ready.mjs';
44
+ import { takeLock } from './lock.mjs';
45
+
46
+ export const SHOT_HELP = `joystack shot <script.mjs> [--size phone|tablet|desktop] [--landscape] [--query <k=v&…>] [--timeout <seconds>] [--out <dir>] [--port <n>]
47
+
48
+ Runs a short Playwright script against the running game and writes the
49
+ pictures it takes to .joystack/shots/<name>.png. The script exports one
50
+ function, handed a page already open on the game:
51
+
52
+ export default async ({ page, game, shot, log }) => {
53
+ await page.keyboard.press('Space'); // real input
54
+ await game.waitFor((w) => w.query('enemy').length >= 3);
55
+ await shot('three-enemies');
56
+ };
57
+
58
+ page the Playwright page, on the game, started and showing what a player
59
+ sees; never goto or launch a browser of your own
60
+ game the world handle openPage gives a smoke (not the page's window.game):
61
+ me(), mine(), ownIds(), entity(), snapshot(), query(selector: a component
62
+ name, tag:<name> or def:<name>), get(), set() (a dev room only),
63
+ send(), driveTo(), waitFor(), pause(), resume(), setTimeScale(),
64
+ players(). Every read is a promise: await game.query('boss')
65
+ shot shot(name) writes the page as it is now; the dev tools are left out.
66
+ game.pause() first makes it a picture of one instant
67
+ log log(...) prints a line
68
+
69
+ Wait for the moment, not for a time: game.waitFor or page.waitForSelector
70
+ wait only until it comes, where page.waitForTimeout is padded for the
71
+ slowest computer and still misses on it. A debug overlay the game has is
72
+ turned on as a player would (its key, or --query), then pictured.
73
+
74
+ --size phone (390×844, the default), tablet (820×1180) or desktop
75
+ (1280×800); phone and tablet at twice the pixels
76
+ --landscape the screen turned on its side
77
+ --query added to the game's address: --query "level=3"
78
+ --timeout seconds the whole script may take (default 60, at most 300);
79
+ each step on the page, and game.waitFor and driveTo, wait at
80
+ most 5 s
81
+ --out the folder the pictures go to (outside the game's own files,
82
+ or each picture is an edit the game reloads for)
83
+ --port the dev server's port, when this game's is not the one found
84
+
85
+ Write the script under .joystack/ (.joystack/look.mjs), which is not part
86
+ of the game. It is checked before anything starts, and the page opens only
87
+ once the dev server has finished updating after an edit; the run stops if
88
+ the game updates while it runs, and fails if the game throws. One page, one
89
+ player: in a multiplayer game it joins the game's one room, beside anyone
90
+ playing, so set(), pause() and setTimeScale() change their game too (a
91
+ time scale is put back to 1 when the script ends). It cannot stage an
92
+ entity the game does not spawn; that is a smoke's, openPage({ stage }). One
93
+ script at a time on a computer. Uses the dev stack already running for this
94
+ game, or starts one for the length of the command. Needs playwright in the
95
+ game (npm i -D playwright).`;
96
+
97
+ export const SIZES = {
98
+ phone: { width: 390, height: 844, scale: 2, touch: true },
99
+ tablet: { width: 820, height: 1180, scale: 2, touch: true },
100
+ desktop: { width: 1280, height: 800, scale: 1, touch: false },
101
+ };
102
+ export const TIMEOUT_DEFAULT_S = 60;
103
+ export const TIMEOUT_MOST_S = 300;
104
+ // How long the game has to start once the page is opened, and how often the
105
+ // dev server is asked whether it moved on while the script runs.
106
+ export const START_MOST_MS = 45_000;
107
+ const WATCH_MS = 500;
108
+
109
+ const VALUED = {
110
+ '--size': ['size', String],
111
+ '--query': ['query', (v) => v.replace(/^\?/, '')],
112
+ '--timeout': ['timeout', Number],
113
+ '--out': ['out', String],
114
+ '--port': ['port', Number],
115
+ };
116
+
117
+ // The arguments after `joystack shot`.
118
+ export function parseShotArgs(rest = []) {
119
+ const opts = { script: null, size: 'phone', landscape: false, query: '', timeout: TIMEOUT_DEFAULT_S, out: null, port: null };
120
+ for (let i = 0; i < rest.length; i += 1) {
121
+ const a = rest[i];
122
+ if (a === '--landscape') { opts.landscape = true; continue; }
123
+ if (a === '--help') continue;
124
+ if (!a.startsWith('--')) {
125
+ if (opts.script) throw new Error(`joystack shot runs one script, given two: ${opts.script} and ${a}`);
126
+ opts.script = a;
127
+ continue;
128
+ }
129
+ if (!VALUED[a]) throw new Error(`joystack shot has no option ${a}.\n\n${SHOT_HELP}`);
130
+ const v = rest[i + 1];
131
+ if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${SHOT_HELP}`);
132
+ const [key, read] = VALUED[a];
133
+ opts[key] = read(v);
134
+ i += 1;
135
+ }
136
+ if (!opts.script) throw new Error(`joystack shot needs the script to run.\n\n${SHOT_HELP}`);
137
+ if (!SIZES[opts.size]) throw new Error(`--size is phone, tablet or desktop, not "${opts.size}"`);
138
+ if (!Number.isFinite(opts.timeout) || opts.timeout <= 0 || opts.timeout > TIMEOUT_MOST_S) throw new Error(`--timeout is a number of seconds above 0 and at most ${TIMEOUT_MOST_S}`);
139
+ if (opts.port !== null && !(Number.isInteger(opts.port) && opts.port > 0 && opts.port < 65536)) throw new Error('--port needs a port number');
140
+ return opts;
141
+ }
142
+
143
+ // The browser context's options for a size.
144
+ export function viewFor(size, landscape) {
145
+ const s = SIZES[size];
146
+ const [width, height] = landscape ? [s.height, s.width] : [s.width, s.height];
147
+ return { viewport: { width, height }, deviceScaleFactor: s.scale, isMobile: s.touch, hasTouch: s.touch };
148
+ }
149
+
150
+ // The dev stack the script runs against: the one running for the game (in the
151
+ // maker, the draft's, on a port of the maker's choosing), or one started with
152
+ // its room server for the length of the command. In the maker none is
153
+ // started: a second dev server beside the draft's would show nobody anything.
154
+ async function stackFor({ cwd, project, port, startDev, env }) {
155
+ const at = makerDraft(env);
156
+ const devPort = port ?? (at ? await makerDevPort(at) : null);
157
+ try {
158
+ return await stackOrigin({ cwd, projectDir: project.dir, port: devPort, startDev: at ? null : startDev, mp: true });
159
+ } catch (err) {
160
+ if (!at) throw err;
161
+ throw new Error(`the draft's game is not running yet (the maker is starting it): try again in a moment (${String(err?.message ?? err).split('\n')[0].slice(0, 200)})`);
162
+ }
163
+ }
164
+
165
+ // Whether the dev server moved on from the version the script started on.
166
+ export const moved = (now, version) => !!now && (now.state !== 'ready' || (now.pending && now.auto !== false) || (version != null && now.version !== version));
167
+
168
+ const RUNNER = fileURLToPath(new URL('./run.mjs', import.meta.url));
169
+ // How long the runner has to close its browser after being asked to stop,
170
+ // before it is killed outright.
171
+ const STOP_GRACE_MS = 2000;
172
+ // How long a stop waits for a dev stack still starting, to stop it as well.
173
+ const STACK_WAIT_MS = 10_000;
174
+
175
+ // Runs the checked script in its own process. Resolves with how it ended:
176
+ // { code } when it exited by itself, { signal } when something else killed it,
177
+ // { moved: true } when the game updated under it, { timedOut: true } when it ran
178
+ // past its limit, { stopped: true } after `stop()`. Returns { done, stop }.
179
+ export function runChild({ projectDir, job, timeoutMs, origin, version, touch = () => {}, env, runner = RUNNER, graceMs = START_MOST_MS + 15_000, watchMs = WATCH_MS }) {
180
+ const preload = joystackTestPreload(projectDir);
181
+ const args = ['--import', SMOKE_PRELOAD, ...(preload ? ['--import', preload] : []), runner];
182
+ const child = spawn(process.execPath, args, { cwd: projectDir, env: { ...env, JOYSTACK_SHOT_JOB: JSON.stringify(job) }, stdio: ['ignore', 'inherit', 'inherit'] });
183
+ let ended = null;
184
+ let resolveP;
185
+ const done = new Promise((r) => { resolveP = r; });
186
+ let asking = false;
187
+ const watch = setInterval(async () => {
188
+ touch();
189
+ // One question at a time: a busy server answers slower than the ticks.
190
+ if (asking) return;
191
+ asking = true;
192
+ try {
193
+ if (moved(await devState(origin), version)) end({ moved: true });
194
+ } finally { asking = false; }
195
+ }, watchMs);
196
+ // The runner keeps its own limit on the script; this one is for a runner
197
+ // stuck past it (a page that stopped answering).
198
+ const limit = setTimeout(() => end({ timedOut: true }), timeoutMs + graceMs);
199
+ let kill = null;
200
+ function end(how) {
201
+ if (ended) return;
202
+ ended = how;
203
+ clearInterval(watch);
204
+ clearTimeout(limit);
205
+ if (how.code === undefined && how.signal === undefined) {
206
+ // Asked first, so Playwright closes its browser; then made to.
207
+ child.kill('SIGTERM');
208
+ kill = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
209
+ }
210
+ }
211
+ child.on('exit', (code, signal) => {
212
+ end(signal && code === null ? { signal } : { code: code ?? 0 });
213
+ clearTimeout(kill);
214
+ resolveP(ended);
215
+ });
216
+ child.on('error', (err) => { end({ code: 1, error: err }); resolveP(ended); });
217
+ return { done, stop: () => end({ stopped: true }) };
218
+ }
219
+
220
+ // The error a run's end makes, or null when it succeeded.
221
+ function failureOf(how, { timeout, interrupted }) {
222
+ if (how.stopped) return Object.assign(new Error(`stopped (${interrupted ?? 'asked'}); the script's browser was closed`), { exitCode: 130 });
223
+ if (how.moved) return new Error('the game updated while the script ran (a file of the game changed), so its page reloaded: run it again once nothing is being edited');
224
+ if (how.timedOut) return new Error(`the script did not finish in ${timeout} s and was stopped`);
225
+ if (how.error) return how.error;
226
+ if (how.signal) return new Error(`the script's process was killed (${how.signal}), most often for want of memory`);
227
+ // The runner has said what went wrong in its own words.
228
+ if (how.code !== 0) return Object.assign(new Error('the script failed'), { reported: true });
229
+ return null;
230
+ }
231
+
232
+ // A stack still starting when a stop came, waited for a while so it can be
233
+ // stopped too (in the maker none is ever started), or null.
234
+ async function lateStack(stacking, { interrupted, log }) {
235
+ if (interrupted) log(' stopping the dev server it was starting…');
236
+ const late = await Promise.race([stacking.catch(() => null), new Promise((r) => setTimeout(r, STACK_WAIT_MS).unref())]);
237
+ return late?.started ?? null;
238
+ }
239
+
240
+ // Run one script. `startDev` starts a dev stack when none is running for the
241
+ // game; `origin` is for a caller that already has one, and `runner` a stand-in
242
+ // for run.mjs (both a test's). Throws with a line fit to print when the run
243
+ // fails; resolves when it succeeded.
244
+ export async function shot({
245
+ cwd = process.cwd(), script, size = 'phone', landscape = false, query = '', timeout = TIMEOUT_DEFAULT_S, out = null, port = null,
246
+ startDev, log = console.log, origin, env = process.env, lockDir, runner,
247
+ } = {}) {
248
+ const file = resolve(cwd, script);
249
+ // Before anything starts, so a script that cannot work fails in a moment.
250
+ checkScript(file);
251
+ const project = findProject(cwd);
252
+ let started = null;
253
+ let stacking = null;
254
+ let stackSettled = false;
255
+ let release = null;
256
+ let child = null;
257
+ let interrupted = null;
258
+ // Ctrl-C, or a tool's limit on the command: the runner and its browser, the
259
+ // stack started for the command and the lock go with it. A wait in progress
260
+ // (the stack starting, an update, the lock) is given up at once. A second
261
+ // signal, while the first is still being acted on, ends the command there
262
+ // and then, giving back the lock.
263
+ let stop;
264
+ const stopped = new Promise((_, reject) => { stop = reject; });
265
+ stopped.catch(() => {});
266
+ const onSignal = (signal) => {
267
+ if (interrupted) {
268
+ // Its stop is begun, not waited for: a started stack's processes are
269
+ // signalled before it returns.
270
+ Promise.resolve().then(() => started?.stop()).catch(() => {});
271
+ release?.();
272
+ process.exit(130);
273
+ }
274
+ interrupted = signal;
275
+ child?.stop();
276
+ stop(Object.assign(new Error(`stopped (${signal})`), { exitCode: 130 }));
277
+ };
278
+ const orStop = (p) => Promise.race([p, stopped]);
279
+ process.on('SIGINT', onSignal);
280
+ process.on('SIGTERM', onSignal);
281
+ try {
282
+ if (!origin) {
283
+ stacking = stackFor({ cwd, project, port, startDev, env });
284
+ // Whatever it started is kept the moment it is, a stop or not.
285
+ stacking.then((r) => { started ??= r?.started ?? null; stackSettled = true; }, () => { stackSettled = true; });
286
+ ({ origin, started } = await orStop(stacking));
287
+ }
288
+ // This game's update is waited out before the lock is taken, so another
289
+ // game's script never waits on it.
290
+ await orStop(untilReady(origin, { log }));
291
+ const taking = takeLock({ dir: lockDir, log });
292
+ // A lock taken after a stop is given back with the rest.
293
+ taking.then((r) => { release = r; if (interrupted) r(); }, () => {});
294
+ release = await orStop(taking);
295
+ const version = await orStop(untilReady(origin, { log }));
296
+ const job = {
297
+ script: file,
298
+ origin,
299
+ slug: project.slug,
300
+ query,
301
+ view: viewFor(size, landscape),
302
+ out: out ? resolve(cwd, out) : stateFolderIn(project.dir, 'shots'),
303
+ projectDir: project.dir,
304
+ timeoutMs: timeout * 1000,
305
+ startMs: START_MOST_MS,
306
+ maker: !!makerDraft(env),
307
+ };
308
+ child = runChild({ projectDir: project.dir, job, timeoutMs: job.timeoutMs, origin, version, touch: release.touch, env, runner });
309
+ const failed = failureOf(await child.done, { timeout, interrupted });
310
+ if (failed) throw failed;
311
+ } finally {
312
+ if (!started && stacking && !stackSettled && !makerDraft(env)) started = await lateStack(stacking, { interrupted, log });
313
+ // Each step on its own, so one that fails, even at once, still lets the
314
+ // next run.
315
+ await Promise.resolve().then(() => started?.stop()).catch(() => {});
316
+ release?.();
317
+ process.removeListener('SIGINT', onSignal);
318
+ process.removeListener('SIGTERM', onSignal);
319
+ }
320
+ }
@@ -5,6 +5,7 @@
5
5
  // /shared/… → the engine bundle's shared/ tree
6
6
  // /__reload → SSE; tells open tabs the version they run on is replaced
7
7
  // /__joystack/dev → the versions' state; reload, and auto-reload on or off
8
+ // /__joystack/warm/<slug>/ → a page that downloads the game's start ahead of it
8
9
  //
9
10
  // Mounts are ORDERED — first prefix match wins. That ordering is how
10
11
  // overrides/shared/… will shadow the engine bundle (Slice 2): the game's
@@ -17,7 +18,8 @@ import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'n
17
18
  import { extname, join, normalize, relative, sep } from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
19
20
  import { gunzipSync } from 'node:zlib';
20
- import { injectHeadTags, injectNoteTag, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL, DEVTOOLS_URL } from './inject.mjs';
21
+ import { injectHeadTags, injectNoteTag, stampedKinds, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL, DEVTOOLS_URL, ENGINE_TOOLS_URL, POINT_URL } from './inject.mjs';
22
+ import { cacheHeaders, closureStamps, declaredAssets, notModified, startFiles, WARM_PAGE, WARM_PATH } from './fingerprint/fingerprint.mjs';
21
23
  import { createRelay, RELAY_PATH } from './relay/relay.mjs';
22
24
  import { WHOAMI_PATH } from './ports.mjs';
23
25
  import {
@@ -70,7 +72,10 @@ const reloadClient = (kind, version, token) => `<script>
70
72
  const version = ${JSON.stringify(version)};
71
73
  let alive = false;
72
74
  let leaving = false;
73
- const leave = () => { if (!leaving) { leaving = true; location.reload(); } };
75
+ // Loaded again by a navigation to its own address: Safari makes a reload
76
+ // without its cache, every module downloaded again however long it may be
77
+ // kept. An address with a #fragment would only move to it, so that one reloads.
78
+ const leave = () => { if (!leaving) { leaving = true; if (location.hash) location.reload(); else location.replace(location.href); } };
74
79
  function connect() {
75
80
  const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
76
81
  es.onopen = () => { alive = true; };
@@ -100,8 +105,13 @@ export function errorSummary(text, gameDir = null) {
100
105
  const lines = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)
101
106
  // Node's own warnings ride along on the build's output.
102
107
  .filter((l) => !/^\(node:\d+\)|^\(Use `node --trace-warnings|^Reparsing as ES module|^To eliminate this warning|package\.json\.$/.test(l));
103
- const first = lines.find((l) => l.startsWith('✘')) ?? lines.find((l) => /error|cannot|not defined|unexpected/i.test(l)) ?? lines[0] ?? '';
104
- let line = first.replace(/^✘\s*/, '').replace(/^\S+:\d+(?::\d+)?\s+—\s+/, '').replace(/^Expansion threw:\s*/, '');
108
+ // A gate refusal first; then the error a build that threw printed (Node
109
+ // prints the line of code that threw above it, which says "Error" too);
110
+ // then any line that reads like one.
111
+ const first = lines.find((l) => l.startsWith('✘'))
112
+ ?? lines.find((l) => /^(?:[A-Z]\w*)?Error:\s/.test(l))
113
+ ?? lines.find((l) => /error|cannot|not defined|unexpected/i.test(l)) ?? lines[0] ?? '';
114
+ let line = first.replace(/^✘\s*/, '').replace(/^\S+:\d+(?::\d+)?\s+—\s+/, '').replace(/^Expansion threw:\s*/, '').replace(/^Error:\s+/, '');
105
115
  if (gameDir) {
106
116
  // Any absolute path into the game folder, however it is spelled: with
107
117
  // either separator, a drive letter, /private on macOS, or a Windows short
@@ -415,6 +425,28 @@ export function createStaticServer({
415
425
  }
416
426
  }
417
427
 
428
+ // The runtime's tag and the tool files it is handed, for a game's page.
429
+ function devtoolsFor(shared, slug) {
430
+ const devtools = devtoolsRide({ shared, shareIsOwner, carried: !!resolveUrl(DEVTOOLS_URL) });
431
+ if (!devtools) return { devtools };
432
+ return { devtools, engineTools: engineToolFiles(), gameTools: toolFiles(`/games/${slug}/tools`), point: !!resolveUrl(POINT_URL) };
433
+ }
434
+
435
+ // The engine's own dev tools, as its list names them, each only when the
436
+ // engine serving the page has its file.
437
+ function engineToolFiles() {
438
+ const list = resolveUrl(ENGINE_TOOLS_URL);
439
+ if (!list) return [];
440
+ try {
441
+ const tools = JSON.parse(readFileSync(list, 'utf8')).tools;
442
+ return (Array.isArray(tools) ? tools : [])
443
+ .map((t) => t?.url)
444
+ .filter((url) => typeof url === 'string' && url.startsWith('/shared/') && !!resolveUrl(url));
445
+ } catch {
446
+ return [];
447
+ }
448
+ }
449
+
418
450
  const reload = new EventEmitter();
419
451
  reload.setMaxListeners(0);
420
452
  // The build a new session is stamped with: the version's, or, serving the
@@ -430,12 +462,36 @@ export function createStaticServer({
430
462
 
431
463
  const gameEntry = `/games/${slug}/index.html`;
432
464
 
465
+ // The page in place of the game while it updates or after its build failed.
466
+ // A failed build answers as an error, so a check that fetches the page (an
467
+ // agent's curl, a test) sees it failed; a browser still shows it. The
468
+ // header says why, so a proxy that passes the page on (the maker's live
469
+ // links) can answer it as the page it is rather than its own 5xx.
470
+ function sendStatus(res, status) {
471
+ const failed = status.state === 'failed';
472
+ return sendBody(res, failed ? 500 : 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html'], failed ? { 'x-joystack-build': 'failed' } : {});
473
+ }
474
+
433
475
  function serveHtml(req, res, fsPath, urlPath, query) {
434
476
  const gamePage = /^\/games\/[^/]+\//.test(urlPath);
435
477
  const status = versions?.status();
436
- if (gamePage && status && status.state !== 'ready') {
437
- return sendBody(res, 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html']);
478
+ if (gamePage && status && status.state !== 'ready') return sendStatus(res, status);
479
+ let html = pageHtml(req, fsPath, urlPath, query);
480
+ if (wantsNote(req)) html = injectNoteTag(html);
481
+ if (versions) {
482
+ let token = 0;
483
+ if (gamePage) {
484
+ token = nextToken++;
485
+ gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
486
+ }
487
+ const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
488
+ html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
438
489
  }
490
+ sendBody(res, 200, html, MIME['.html']);
491
+ }
492
+
493
+ // A page as this server serves it, before the note and the reload client.
494
+ function pageHtml(req, fsPath, urlPath, query) {
439
495
  let html = readFileSync(fsPath, 'utf8');
440
496
  html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
441
497
  html = withLegacyImportMap(html);
@@ -464,34 +520,53 @@ export function createStaticServer({
464
520
  const tabIdentity = as
465
521
  ? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
466
522
  : (shared ? shareGuestIdentity() : identity);
467
- // The toolbox rides only when the resolved engine carries it — an older
468
- // pinned engine gets no 404ing tag (it gains the toolbox at `joystack
469
- // update`). Per-request, so a bundle swap mid-session is honoured.
523
+ // The old toolbox rides only when the resolved engine carries it (0.2.46
524
+ // and earlier); a newer engine has the dev tools runtime instead.
525
+ // Per-request, so a bundle swap mid-session is honoured.
470
526
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
471
- const devtools = devtoolsRide({ shared, shareIsOwner, carried: !!resolveUrl(DEVTOOLS_URL) });
527
+ const stamps = closureStamps(resolveUrl);
528
+ const stamp = (url) => { const v = stamps.stamp(url); return v ? `${url}?v=${v}` : url; };
472
529
  html = injectHeadTags(html, m[1], {
530
+ stamp,
473
531
  ...(tabIdentity ? { identity: tabIdentity } : {}),
474
532
  toolbox,
475
- devtools,
476
- gameTools: devtools ? toolFiles(`/games/${m[1]}/tools`) : [],
477
- skeleton: currentSkeleton,
478
- ...(shared ? { shareHost } : {}),
533
+ ...devtoolsFor(shared, m[1]),
534
+ skeleton: currentSkeleton && stampedKinds(currentSkeleton, urlPath, stamp),
535
+ ...(shared ? { shareHost, replay: shareIsOwner } : {}),
479
536
  });
480
537
  }
481
- if (wantsNote(req)) html = injectNoteTag(html);
482
- if (versions) {
483
- let token = 0;
484
- if (gamePage) {
485
- token = nextToken++;
486
- gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
487
- }
488
- const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
489
- html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
538
+ return html;
539
+ }
540
+
541
+ // GET /__joystack/warm/<slug>/ the warm page (WARM_PAGE)
542
+ // GET /__joystack/warm/<slug>/files.json { files: [url…] }
543
+ //
544
+ // The files the game's start downloads: its page's stamped closure as this
545
+ // request's page would name it (fingerprint/, startFiles), then the assets
546
+ // its assets.js declares, which it asks for under their plain URLs. The
547
+ // page is not a game page: it carries no platform layer, no dev tools, no
548
+ // recording and no room, and a version switch does not wait for it. While
549
+ // no version is ready the list is empty.
550
+ async function serveWarm(req, res, urlPath) {
551
+ const m = /^\/__joystack\/warm\/([^/]+)\/(files\.json)?$/.exec(urlPath);
552
+ if (!m || decodeURIComponent(m[1]) !== slug) {
553
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
554
+ return res.end('404');
490
555
  }
491
- sendBody(res, 200, html, MIME['.html']);
556
+ if (!m[2]) return sendBody(res, 200, WARM_PAGE, MIME['.html']);
557
+ const status = versions?.status();
558
+ const fsPath = resolveUrl(gameEntry);
559
+ let files = [];
560
+ if (fsPath && (!status || status.state === 'ready')) {
561
+ const html = pageHtml(req, fsPath, gameEntry, new URLSearchParams());
562
+ const { engineTools = [], gameTools = [] } = devtoolsFor(isShareHost(req.headers.host, shareHost), slug);
563
+ const plain = [...engineTools, ...gameTools];
564
+ files = [...startFiles({ html, pageUrl: gameEntry, resolveUrl, plain }), ...(await declaredAssets({ slug, resolveUrl }))];
565
+ }
566
+ return sendBody(res, 200, JSON.stringify({ files }), 'application/json');
492
567
  }
493
568
 
494
- function serveJs(res, fsPath, urlPath) {
569
+ function serveJs(req, res, fsPath, urlPath, query) {
495
570
  const raw = readFileSync(fsPath);
496
571
  let body;
497
572
  try {
@@ -499,20 +574,44 @@ export function createStaticServer({
499
574
  } catch {
500
575
  body = raw;
501
576
  }
502
- sendBody(res, 200, body, MIME['.js']);
577
+ sendFile(req, res, body, MIME['.js'], urlPath, query);
578
+ }
579
+
580
+ // A file of the game or the engine (never a page): kept by the browser for
581
+ // good under a URL whose stamp is current, otherwise checked with this
582
+ // server by its tag (fingerprint/).
583
+ function sendFile(req, res, body, type, urlPath, query) {
584
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
585
+ const requested = query?.get('v') ?? null;
586
+ const headers = cacheHeaders({ requested, current: requested ? closureStamps(resolveUrl).stamp(urlPath) : null, body: buf });
587
+ if (headers.ETag && notModified(req, headers.ETag)) {
588
+ // Closed after: the maker's container proxy (workerd) fails the next
589
+ // request on a connection that has carried a bodiless 304 ("Container
590
+ // port connection closed unexpectedly").
591
+ res.writeHead(304, { ...headers, 'Access-Control-Allow-Origin': '*', Connection: 'close' });
592
+ return res.end();
593
+ }
594
+ res.writeHead(200, {
595
+ ...headers,
596
+ 'Content-Type': type,
597
+ 'Content-Length': buf.length,
598
+ 'Access-Control-Allow-Origin': '*',
599
+ });
600
+ res.end(req.method === 'HEAD' ? undefined : buf);
503
601
  }
504
602
 
505
- function sendBody(res, status, body, type) {
603
+ function sendBody(res, status, body, type, headers = {}) {
506
604
  const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
507
605
  res.writeHead(status, {
606
+ ...headers,
508
607
  'Content-Type': type,
509
608
  'Content-Length': buf.length,
510
609
  'Cache-Control': 'no-store, must-revalidate',
511
610
  'Access-Control-Allow-Origin': '*',
512
- // Only a page of this server's own may frame one of its pages. The
513
- // toolbox host (shared/ui/toolbox/host.html) listens and publishes on
514
- // the relay for whatever page frames it, so a page on another site that
515
- // framed it could wake the agent and read every event. The maker frames
611
+ // Only a page of this server's own may frame one of its pages. The dev
612
+ // tools runtime (shared/ui/devtools/runtime.js) takes the page that
613
+ // frames it as its host: a page on another site that framed it could
614
+ // write into the game's world and receive every pick. The maker frames
516
615
  // live links from its own origin, which its Worker names in place of
517
616
  // this on every page it passes on.
518
617
  ...(type === MIME['.html'] ? { 'Content-Security-Policy': "frame-ancestors 'self'" } : {}),
@@ -965,6 +1064,9 @@ export function createStaticServer({
965
1064
  const reqUrl = new URL(req.url, 'http://x');
966
1065
  const urlPath = canonicalDevPath(reqUrl.pathname);
967
1066
  if (urlPath === '/__reload') return handleSse(res, reqUrl.searchParams);
1067
+ if (urlPath.startsWith(`${WARM_PATH}/`)) {
1068
+ return serveWarm(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1069
+ }
968
1070
  if (versions && (urlPath === DEV_CONTROL_PATH || urlPath.startsWith(`${DEV_CONTROL_PATH}/`))) {
969
1071
  return handleDevControl(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
970
1072
  }
@@ -1053,8 +1155,8 @@ export function createStaticServer({
1053
1155
  const ext = extname(fsPath).toLowerCase();
1054
1156
  try {
1055
1157
  if (ext === '.html') return serveHtml(req, res, fsPath, urlPath, reqUrl.searchParams);
1056
- if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath, urlPath);
1057
- sendBody(res, 200, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream');
1158
+ if (ext === '.js' || ext === '.mjs') return serveJs(req, res, fsPath, urlPath, reqUrl.searchParams);
1159
+ sendFile(req, res, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream', urlPath, reqUrl.searchParams);
1058
1160
  } catch {
1059
1161
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1060
1162
  res.end('500');
package/lib/test-cmd.mjs CHANGED
@@ -47,18 +47,35 @@ export const posixRelative = (dir, file, p = nodePath) => p.relative(dir, file).
47
47
  // The path module is injectable for the same reason: with `path.win32` the
48
48
  // real readdir → join → relative → skip chain runs with Windows semantics
49
49
  // against real files, on any host.
50
- export function discoverTestFiles(dir, { path: p = nodePath } = {}) {
50
+ //
51
+ // The walk never opens a folder it skips: the dev server keeps changing what
52
+ // is under .joystack/ (frozen copies come and go) and node_modules/ is large,
53
+ // and listing everything to filter after fails the whole run on a file that
54
+ // vanished mid-listing. A folder that vanishes while the walk looks at it is
55
+ // simply not there. `readdir` is injectable to check both.
56
+ export function discoverTestFiles(dir, { path: p = nodePath, readdir = readdirSync } = {}) {
51
57
  const unit = [];
52
58
  const smokes = [];
53
- const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
54
- for (const entry of entries) {
55
- if (!entry.isFile()) continue;
56
- // `parentPath` exists from node 20.12; the 20.11 floor names the same value `path`.
57
- const path = p.join(entry.parentPath ?? entry.path, entry.name);
58
- if (isSkippedPath(p.relative(dir, path))) continue;
59
- if (entry.name.endsWith('.test.mjs')) unit.push(path);
60
- else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
61
- }
59
+ // `real` is the folder as this host's file system names it; `shown`, as the
60
+ // injected path module does (the two differ only under path.win32).
61
+ const walk = (real, shown) => {
62
+ let entries;
63
+ try {
64
+ entries = readdir(real, { withFileTypes: true });
65
+ } catch (err) {
66
+ if (err?.code === 'ENOENT' && real !== dir) return;
67
+ throw err;
68
+ }
69
+ for (const entry of entries) {
70
+ const path = p.join(shown, entry.name);
71
+ if (isSkippedPath(p.relative(dir, path))) continue;
72
+ if (entry.isDirectory()) walk(nodePath.join(real, entry.name), path);
73
+ else if (!entry.isFile()) continue;
74
+ else if (entry.name.endsWith('.test.mjs')) unit.push(path);
75
+ else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
76
+ }
77
+ };
78
+ walk(dir, dir);
62
79
  unit.sort();
63
80
  smokes.sort();
64
81
  return { unit, smokes };
@@ -330,7 +347,7 @@ async function freeTestPort(base = 8100) {
330
347
 
331
348
  // Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
332
349
  // A file URL so `node --import` resolves it regardless of the smoke's cwd.
333
- const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
350
+ export const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
334
351
 
335
352
  // The compact node:test reporter (see test-reporter.mjs), as a file URL for the
336
353
  // same reason.
@@ -343,7 +360,7 @@ const REPORTER = new URL('./test-reporter.mjs', import.meta.url).href;
343
360
  // engine is absent (pruned by an npm install — the next dev/publish self-heals
344
361
  // it) or too old to ship the preload — the spawn then runs without it, as
345
362
  // before.
346
- function joystackTestPreload(projectDir) {
363
+ export function joystackTestPreload(projectDir) {
347
364
  try {
348
365
  const preload = join(resolveEngine(projectDir).dir, 'shared', 'framework', 'build', 'joystack-test-preload.mjs');
349
366
  return existsSync(preload) ? pathToFileURL(preload).href : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
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",