@joystack/cli 0.1.54 → 0.1.56

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.
@@ -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,8 +18,11 @@ 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, ENGINE_TOOLS_URL, POINT_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, freshOfRequest, freshToken, notModified, splitStamp, stampValue, startFiles, WARM_PAGE, WARM_PATH } from './fingerprint/fingerprint.mjs';
21
23
  import { createRelay, RELAY_PATH } from './relay/relay.mjs';
24
+ import { createStartFailed, START_FAILED_PATH } from './start-failed.mjs';
25
+ import { MIME } from './mime.mjs';
22
26
  import { WHOAMI_PATH } from './ports.mjs';
23
27
  import {
24
28
  writeSegment, stitchStream, streamDir, listRecordings, latestRecording, deleteRecording, setKept,
@@ -27,6 +31,7 @@ import {
27
31
  import { gameBuild } from './build-id.mjs';
28
32
  import { createHistory, checkPath, HISTORY_PATH, HistoryError } from './history.mjs';
29
33
  import { STATE_DIR_NAMES, canonicalDevPath, stateDir, stateDirName, withLegacyImportMap } from './legacy-names.mjs';
34
+ import { reloadClient } from './reload-client.mjs';
30
35
 
31
36
  // `?replay=<id>` resolves to `.joystack/replays/<id>.json` (replaySourceUrl in the
32
37
  // engine's replay transport). Nothing writes that FILE — the room writes the
@@ -50,48 +55,6 @@ const REPLAY_URL_RE = new RegExp(`^/games/[^/]+/(?:${STATE_DIRS_RE})/replays/([a
50
55
  // one of them carries a dash and none of them is a word.
51
56
  const LATEST_ALIAS = 'last';
52
57
 
53
- // The page's half of /__reload, under `joystack dev` (live-versions.mjs). Every
54
- // page the server serves belongs to one version of the game, and leaves when
55
- // that version stops being the one served:
56
- //
57
- // game a game page. It reloads as soon as an update starts, so no game
58
- // runs while files are replaced; the server answers it with the
59
- // "Updating…" page, then the game.
60
- // status the "Updating…" or error page. It reloads when a version is ready.
61
- // other any other page (the Assets page). It reloads once the new version
62
- // is served.
63
- //
64
- // The stream sends `reload` after every switch, and the status (JSON) on
65
- // connecting and whenever it changes. A page whose version is not the one in
66
- // the status reloads at once: it was served just before a switch.
67
- const reloadClient = (kind, version, token) => `<script>
68
- (() => {
69
- const kind = ${JSON.stringify(kind)};
70
- const version = ${JSON.stringify(version)};
71
- let alive = false;
72
- let leaving = false;
73
- const leave = () => { if (!leaving) { leaving = true; location.reload(); } };
74
- function connect() {
75
- const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
76
- es.onopen = () => { alive = true; };
77
- es.onmessage = (e) => {
78
- if (e.data === 'reload') return leave();
79
- let s;
80
- try { s = JSON.parse(e.data); } catch { return; }
81
- if (kind === 'game' && (s.state !== 'ready' || s.version !== version)) return leave();
82
- if (kind === 'status' && s.state === 'ready') return leave();
83
- if (kind === 'status') window.dispatchEvent(new CustomEvent('joystack-status', { detail: s }));
84
- };
85
- es.onerror = () => {
86
- es.close();
87
- setTimeout(connect, alive ? 200 : 1000);
88
- alive = false;
89
- };
90
- }
91
- connect();
92
- })();
93
- </script>`;
94
-
95
58
  // The one line of a failed build a person needs: the first error it reports,
96
59
  // with the game's paths written from the game folder and the build's own
97
60
  // location left off. The whole error stays in the terminal and in
@@ -100,8 +63,13 @@ export function errorSummary(text, gameDir = null) {
100
63
  const lines = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)
101
64
  // Node's own warnings ride along on the build's output.
102
65
  .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*/, '');
66
+ // A gate refusal first; then the error a build that threw printed (Node
67
+ // prints the line of code that threw above it, which says "Error" too);
68
+ // then any line that reads like one.
69
+ const first = lines.find((l) => l.startsWith('✘'))
70
+ ?? lines.find((l) => /^(?:[A-Z]\w*)?Error:\s/.test(l))
71
+ ?? lines.find((l) => /error|cannot|not defined|unexpected/i.test(l)) ?? lines[0] ?? '';
72
+ let line = first.replace(/^✘\s*/, '').replace(/^\S+:\d+(?::\d+)?\s+—\s+/, '').replace(/^Expansion threw:\s*/, '').replace(/^Error:\s+/, '');
105
73
  if (gameDir) {
106
74
  // Any absolute path into the game folder, however it is spelled: with
107
75
  // either separator, a drive letter, /private on macOS, or a Windows short
@@ -210,8 +178,9 @@ function trustedHost(header, shareHost = null) {
210
178
  // gives an attacker both halves at once (see trustedHost).
211
179
  //
212
180
  // `requireOrigin` turns the absent-header case from pass into fail, and is what
213
- // separates "a page this server served" from "any program that can reach this
214
- // port". A browser attaches Origin to every request whose method is not GET or
181
+ // separates "a page this server served" from a program that sends no Origin.
182
+ // It does not stop a program on this machine, which can write any Origin and
183
+ // Host it likes; only a browser is bound by them. A browser attaches Origin to every request whose method is not GET or
215
184
  // HEAD, same-origin ones included — so on the MUTATING verbs the header is free
216
185
  // to demand and refuses every non-browser caller. A GET cannot demand it: a
217
186
  // same-origin GET carries no Origin either, so the game page fetching its own
@@ -242,6 +211,12 @@ function devtoolsRide({ shared, shareIsOwner, carried }) {
242
211
  return carried && (!shared || shareIsOwner);
243
212
  }
244
213
 
214
+ // The pages that may frame one of this server's: its own, under either
215
+ // loopback name, on its own port alone.
216
+ export function frameAncestors(port) {
217
+ return port ? `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}` : "frame-ancestors 'self'";
218
+ }
219
+
245
220
  // Whether a Host header names the shared tunnel hostname (with or without a port).
246
221
  function isShareHost(header, shareHost) {
247
222
  return !!shareHost && String(header ?? '').replace(/:\d+$/, '').toLowerCase() === shareHost;
@@ -255,34 +230,6 @@ function shareGuestIdentity() {
255
230
  return { userId: `dev-share-${id}`, name: `guest-${id.slice(0, 4)}`, color: '#a3e635' };
256
231
  }
257
232
 
258
- const MIME = {
259
- '.html': 'text/html; charset=utf-8',
260
- '.js': 'text/javascript; charset=utf-8',
261
- '.mjs': 'text/javascript; charset=utf-8',
262
- '.css': 'text/css; charset=utf-8',
263
- '.json': 'application/json; charset=utf-8',
264
- '.svg': 'image/svg+xml',
265
- '.png': 'image/png',
266
- '.jpg': 'image/jpeg',
267
- '.jpeg': 'image/jpeg',
268
- '.gif': 'image/gif',
269
- '.webp': 'image/webp',
270
- '.ico': 'image/x-icon',
271
- '.wasm': 'application/wasm',
272
- '.mp3': 'audio/mpeg',
273
- '.ogg': 'audio/ogg',
274
- '.wav': 'audio/wav',
275
- '.m4a': 'audio/mp4',
276
- '.flac': 'audio/flac',
277
- '.txt': 'text/plain; charset=utf-8',
278
- '.md': 'text/markdown; charset=utf-8',
279
- '.woff': 'font/woff',
280
- '.woff2': 'font/woff2',
281
- '.glb': 'model/gltf-binary',
282
- '.gltf': 'model/gltf+json',
283
- '.bin': 'application/octet-stream',
284
- };
285
-
286
233
  export function createStaticServer({
287
234
  slug,
288
235
  mounts,
@@ -314,8 +261,10 @@ export function createStaticServer({
314
261
  // this machine wakes that session, and the note rides on every page.
315
262
  agentSession = null,
316
263
  // Whether a request through the share link is the creator's own (startDev's
317
- // `shareIsOwner`): its relay events then count as from this machine.
264
+ // `shareIsOwner`): its relay requests then count as from this machine.
318
265
  shareIsOwner = false,
266
+ // The relay's feed for Claude Code (relay/relay.mjs); off in the maker.
267
+ agentFeed = true,
319
268
  }) {
320
269
  // The tunnel hostname `joystack dev --share` opened to this server, once it is
321
270
  // up (setShareHost). See trustedHost.
@@ -327,13 +276,12 @@ export function createStaticServer({
327
276
  // The creator's own browser: on this machine, or through a share link that
328
277
  // only the creator can reach the dev tools by.
329
278
  const fromCreator = (req) => fromThisMachine(req) || (shareIsOwner && isShareHost(req.headers.host, shareHost));
330
- // The relay's hub (relay/relay.mjs): events between the pages this server
331
- // serves and the agent. Without a projectDir there is no game to relay for
332
- // and the routes do not exist.
279
+ // The relay's dev server end (relay/relay.mjs), run from the engine's own
280
+ // relay. Without a projectDir there is no game to relay for and the
281
+ // routes do not exist.
333
282
  const relay = projectDir ? createRelay({
334
- projectDir, agentSession, log, cliBin: CLI_BIN,
335
- sameOrigin: (req) => sameOrigin(req, { shareHost }),
336
- fromCreator,
283
+ projectDir, agentSession, log, cliBin: CLI_BIN, sharedDir: () => engineSharedDir, feed: agentFeed,
284
+ sameOrigin: (req, opts) => sameOrigin(req, { ...opts, shareHost }), fromCreator,
337
285
  }) : null;
338
286
  // The laptop note (NOTE_URL) rides on every page the creator opens, when
339
287
  // the coding-agent session that started this server is there to be woken.
@@ -363,7 +311,7 @@ export function createStaticServer({
363
311
  }
364
312
  // The state folder (`.joystack/`) is the game's private working directory, not part of the site.
365
313
  // It holds the relay's files (the notes the creator sent the agent, with
366
- // their pictures, and the hub's bearer token) and the recorded sessions (which carry every player's entity seed, every
314
+ // their pictures, and the relay's bearer token for the agent) and the recorded sessions (which carry every player's entity seed, every
367
315
  // non-replicated field, and every participant's account id — strictly more
368
316
  // than any single live client is ever sent). This server binds every
369
317
  // interface and stamps `Access-Control-Allow-Origin: *`, so serving that
@@ -452,14 +400,53 @@ export function createStaticServer({
452
400
 
453
401
  const gameEntry = `/games/${slug}/index.html`;
454
402
 
403
+ // The page in place of the game while it updates or after its build failed.
404
+ // A failed build answers as an error, so a check that fetches the page (an
405
+ // agent's curl, a test) sees it failed; a browser still shows it. The
406
+ // header says why, so a proxy that passes the page on (the maker's live
407
+ // links) can answer it as the page it is rather than its own 5xx.
408
+ function sendStatus(res, status) {
409
+ const failed = status.state === 'failed';
410
+ return sendBody(res, failed ? 500 : 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html'], failed ? { 'x-joystack-build': 'failed' } : {});
411
+ }
412
+
455
413
  function serveHtml(req, res, fsPath, urlPath, query) {
456
414
  const gamePage = /^\/games\/[^/]+\//.test(urlPath);
457
415
  const status = versions?.status();
458
- if (gamePage && status && status.state !== 'ready') {
459
- return sendBody(res, 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html']);
416
+ if (gamePage && status && status.state !== 'ready') return sendStatus(res, status);
417
+ let html = pageHtml(req, fsPath, urlPath, query);
418
+ const note = wantsNote(req);
419
+ // Under its stamp: the overlay imports the note by that URL, and a module
420
+ // loaded under two URLs runs twice.
421
+ if (note) html = injectNoteTag(html, stamper(freshToken(query?.get('fresh')))(NOTE_URL));
422
+ // The reload client also carries the relay's announcements (an agent is
423
+ // there, a note's reply), so a page with the note gets it even from a
424
+ // server with no versions, which never tells it to reload.
425
+ if (versions || note) {
426
+ let token = 0;
427
+ if (versions && gamePage) {
428
+ token = nextToken++;
429
+ gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
430
+ }
431
+ const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
432
+ html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
460
433
  }
434
+ sendBody(res, 200, html, MIME['.html']);
435
+ }
436
+
437
+ // A page as this server serves it, before the note and the reload client.
438
+ // `?fresh=<n>` names every stamp `?v=<stamp>.<n>` (fingerprint/, freshToken).
439
+ // A URL under the shared folders named with its current stamp, and the
440
+ // page's fresh token; a URL the engine does not carry stays as it is.
441
+ function stamper(fresh) {
442
+ const stamps = closureStamps(resolveUrl);
443
+ return (url) => { const v = stamps.stamp(url); return v ? `${url}?v=${stampValue(v, fresh)}` : url; };
444
+ }
445
+
446
+ function pageHtml(req, fsPath, urlPath, query) {
447
+ const fresh = freshToken(query?.get('fresh'));
461
448
  let html = readFileSync(fsPath, 'utf8');
462
- html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
449
+ html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl, fresh });
463
450
  html = withLegacyImportMap(html);
464
451
  // Mirror the production /g/<slug> entry injection — only when the resolved
465
452
  // engine actually ships the platform layer (transition shim, same as
@@ -490,41 +477,90 @@ export function createStaticServer({
490
477
  // and earlier); a newer engine has the dev tools runtime instead.
491
478
  // Per-request, so a bundle swap mid-session is honoured.
492
479
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
480
+ const stamp = stamper(fresh);
493
481
  html = injectHeadTags(html, m[1], {
482
+ stamp,
494
483
  ...(tabIdentity ? { identity: tabIdentity } : {}),
495
484
  toolbox,
496
485
  ...devtoolsFor(shared, m[1]),
497
- skeleton: currentSkeleton,
486
+ skeleton: currentSkeleton && stampedKinds(currentSkeleton, urlPath, stamp),
487
+ fresh,
488
+ report: START_FAILED_PATH,
498
489
  ...(shared ? { shareHost, replay: shareIsOwner } : {}),
499
490
  });
500
491
  }
501
- if (wantsNote(req)) html = injectNoteTag(html);
502
- if (versions) {
503
- let token = 0;
504
- if (gamePage) {
505
- token = nextToken++;
506
- gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
507
- }
508
- const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
509
- html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
492
+ return html;
493
+ }
494
+
495
+ // GET /__joystack/warm/<slug>/ the warm page (WARM_PAGE)
496
+ // GET /__joystack/warm/<slug>/files.json { files: [url…] }
497
+ //
498
+ // The files the game's start downloads: its page's stamped closure as this
499
+ // request's page would name it (fingerprint/, startFiles), then the assets
500
+ // its assets.js declares, which it asks for under their plain URLs. The
501
+ // page is not a game page: it carries no platform layer, no dev tools, no
502
+ // recording and no room, and a version switch does not wait for it. While
503
+ // no version is ready the list is empty.
504
+ async function serveWarm(req, res, urlPath) {
505
+ const m = /^\/__joystack\/warm\/([^/]+)\/(files\.json)?$/.exec(urlPath);
506
+ if (!m || decodeURIComponent(m[1]) !== slug) {
507
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
508
+ return res.end('404');
510
509
  }
511
- sendBody(res, 200, html, MIME['.html']);
510
+ if (!m[2]) return sendBody(res, 200, WARM_PAGE, MIME['.html']);
511
+ const status = versions?.status();
512
+ const fsPath = resolveUrl(gameEntry);
513
+ let files = [];
514
+ if (fsPath && (!status || status.state === 'ready')) {
515
+ const html = pageHtml(req, fsPath, gameEntry, new URLSearchParams());
516
+ const { engineTools = [], gameTools = [] } = devtoolsFor(isShareHost(req.headers.host, shareHost), slug);
517
+ const plain = [...engineTools, ...gameTools];
518
+ files = [...startFiles({ html, pageUrl: gameEntry, resolveUrl, plain }), ...(await declaredAssets({ slug, resolveUrl }))];
519
+ }
520
+ return sendBody(res, 200, JSON.stringify({ files }), 'application/json');
512
521
  }
513
522
 
514
- function serveJs(res, fsPath, urlPath) {
523
+ // A module asked for under a fresh address, or by its plain URL from a
524
+ // fresh page, names its imports with the same token (fingerprint/).
525
+ function serveJs(req, res, fsPath, urlPath, query) {
515
526
  const raw = readFileSync(fsPath);
527
+ const fresh = splitStamp(query?.get('v')).fresh ?? (query?.has('v') ? null : freshOfRequest(req, query));
516
528
  let body;
517
529
  try {
518
- body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl });
530
+ body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl, fresh });
519
531
  } catch {
520
532
  body = raw;
521
533
  }
522
- sendBody(res, 200, body, MIME['.js']);
534
+ sendFile(req, res, body, MIME['.js'], urlPath, query);
535
+ }
536
+
537
+ // A file of the game or the engine (never a page): kept by the browser for
538
+ // good under a URL whose stamp is current, otherwise checked with this
539
+ // server by its tag (fingerprint/).
540
+ function sendFile(req, res, body, type, urlPath, query) {
541
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
542
+ const requested = splitStamp(query?.get('v') ?? null).stamp;
543
+ const headers = cacheHeaders({ requested, current: requested ? closureStamps(resolveUrl).stamp(urlPath) : null, body: buf });
544
+ if (headers.ETag && notModified(req, headers.ETag)) {
545
+ // Closed after: the maker's container proxy (workerd) fails the next
546
+ // request on a connection that has carried a bodiless 304 ("Container
547
+ // port connection closed unexpectedly").
548
+ res.writeHead(304, { ...headers, 'Access-Control-Allow-Origin': '*', Connection: 'close' });
549
+ return res.end();
550
+ }
551
+ res.writeHead(200, {
552
+ ...headers,
553
+ 'Content-Type': type,
554
+ 'Content-Length': buf.length,
555
+ 'Access-Control-Allow-Origin': '*',
556
+ });
557
+ res.end(req.method === 'HEAD' ? undefined : buf);
523
558
  }
524
559
 
525
- function sendBody(res, status, body, type) {
560
+ function sendBody(res, status, body, type, headers = {}) {
526
561
  const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
527
562
  res.writeHead(status, {
563
+ ...headers,
528
564
  'Content-Type': type,
529
565
  'Content-Length': buf.length,
530
566
  'Cache-Control': 'no-store, must-revalidate',
@@ -532,10 +568,12 @@ export function createStaticServer({
532
568
  // Only a page of this server's own may frame one of its pages. The dev
533
569
  // tools runtime (shared/ui/devtools/runtime.js) takes the page that
534
570
  // frames it as its host: a page on another site that framed it could
535
- // write into the game's world and receive every pick. The maker frames
536
- // live links from its own origin, which its Worker names in place of
537
- // this on every page it passes on.
538
- ...(type === MIME['.html'] ? { 'Content-Security-Policy': "frame-ancestors 'self'" } : {}),
571
+ // write into the game's world and receive every pick. This server
572
+ // under its other loopback name is listed too: the same files, and the
573
+ // testbed's relay check frames the game across the two. The maker
574
+ // frames live links from its own origin, which its Worker names in
575
+ // place of this on every page it passes on.
576
+ ...(type === MIME['.html'] ? { 'Content-Security-Policy': frameAncestors(server.address()?.port) } : {}),
539
577
  });
540
578
  res.end(buf);
541
579
  }
@@ -810,7 +848,17 @@ export function createStaticServer({
810
848
  return json(200, { stream, deleted: true });
811
849
  }
812
850
 
813
- function handleSse(res, query) {
851
+ // Whether a reload stream was opened by the creator's own page, served by
852
+ // this server: only such a stream hears the relay's announcements (an
853
+ // agent is there, a note's reply). A browser names where a request came
854
+ // from; a page from another site is refused even where it could not read
855
+ // the stream.
856
+ function creatorStream(req) {
857
+ const site = req.headers['sec-fetch-site'];
858
+ return fromCreator(req) && sameOrigin(req, { shareHost }) && (site === undefined || site === 'same-origin' || site === 'none');
859
+ }
860
+
861
+ function handleSse(req, res, query) {
814
862
  res.writeHead(200, {
815
863
  'Content-Type': 'text/event-stream',
816
864
  'Cache-Control': 'no-cache',
@@ -826,8 +874,12 @@ export function createStaticServer({
826
874
  keepalive.unref();
827
875
  reload.on('reload', push);
828
876
  reload.on('status', pushStatus);
877
+ const stopRelay = relay && creatorStream(req)
878
+ ? relay.listen((name, data) => res.write(`event: relay\ndata: ${JSON.stringify({ name, data })}\n\n`))
879
+ : () => {};
829
880
  res.on('close', () => {
830
881
  clearInterval(keepalive);
882
+ stopRelay();
831
883
  reload.off('reload', push);
832
884
  reload.off('status', pushStatus);
833
885
  if (page) {
@@ -883,6 +935,21 @@ export function createStaticServer({
883
935
  return json(404, { error: 'not found' });
884
936
  }
885
937
 
938
+ // A start that failed in a browser, written to the log (start-failed.mjs).
939
+ const handleStartFailed = createStartFailed({
940
+ log,
941
+ // The block reaches the creator's agent: a tester on the share link never
942
+ // writes one, the creator on their own link (the maker's) does.
943
+ allowed: (req) => sameOrigin(req, { requireOrigin: true, shareHost }) && (shareIsOwner || !isShareHost(req.headers.host, shareHost)),
944
+ from: (req) => (isShareHost(req.headers.host, shareHost) ? 'the share link'
945
+ : fromThisMachine(req) ? 'this machine'
946
+ : `${String(req.socket.remoteAddress ?? 'another device').replace(/^::ffff:/, '')} on your network`),
947
+ ownHost: (host) => trustedHost(host, shareHost),
948
+ slug,
949
+ projectDir,
950
+ resolveUrl: (urlPath) => resolveUrl(urlPath),
951
+ });
952
+
886
953
  // ── model history: the game's files at an earlier commit ────────────────
887
954
  //
888
955
  // GET /__joystack/history?path=<p>&path=<p>… the commits that touched those
@@ -984,7 +1051,13 @@ export function createStaticServer({
984
1051
  const server = http.createServer((req, res) => {
985
1052
  const reqUrl = new URL(req.url, 'http://x');
986
1053
  const urlPath = canonicalDevPath(reqUrl.pathname);
987
- if (urlPath === '/__reload') return handleSse(res, reqUrl.searchParams);
1054
+ if (urlPath === '/__reload') return handleSse(req, res, reqUrl.searchParams);
1055
+ if (urlPath.startsWith(`${WARM_PATH}/`)) {
1056
+ return serveWarm(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1057
+ }
1058
+ if (urlPath === START_FAILED_PATH) {
1059
+ return handleStartFailed(req, res).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1060
+ }
988
1061
  if (versions && (urlPath === DEV_CONTROL_PATH || urlPath.startsWith(`${DEV_CONTROL_PATH}/`))) {
989
1062
  return handleDevControl(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
990
1063
  }
@@ -1058,7 +1131,8 @@ export function createStaticServer({
1058
1131
  }
1059
1132
 
1060
1133
  if (urlPath === '/' || urlPath === '/index.html') {
1061
- res.writeHead(302, { Location: gameEntry });
1134
+ // The query goes along: `?fresh=` is for the game's page.
1135
+ res.writeHead(302, { Location: gameEntry + reqUrl.search });
1062
1136
  return res.end();
1063
1137
  }
1064
1138
  let fsPath = resolveUrl(urlPath);
@@ -1073,8 +1147,8 @@ export function createStaticServer({
1073
1147
  const ext = extname(fsPath).toLowerCase();
1074
1148
  try {
1075
1149
  if (ext === '.html') return serveHtml(req, res, fsPath, urlPath, reqUrl.searchParams);
1076
- if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath, urlPath);
1077
- sendBody(res, 200, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream');
1150
+ if (ext === '.js' || ext === '.mjs') return serveJs(req, res, fsPath, urlPath, reqUrl.searchParams);
1151
+ sendFile(req, res, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream', urlPath, reqUrl.searchParams);
1078
1152
  } catch {
1079
1153
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1080
1154
  res.end('500');
package/lib/test-cmd.mjs CHANGED
@@ -21,6 +21,7 @@ import { StringDecoder } from 'node:string_decoder';
21
21
  import { findProject, resolveEngine } from './project.mjs';
22
22
  import { dev } from './dev.mjs';
23
23
  import { lintCmd } from './lint.mjs';
24
+ import { ensureEngine, ensureNamedEngine, namedEngineFailure } from './engine.mjs';
24
25
  import { portsFor, portInUse } from './ports.mjs';
25
26
  import { terminateChildTree } from './process-tree.mjs';
26
27
  import { summaryLines } from './test-reporter.mjs';
@@ -47,18 +48,35 @@ export const posixRelative = (dir, file, p = nodePath) => p.relative(dir, file).
47
48
  // The path module is injectable for the same reason: with `path.win32` the
48
49
  // real readdir → join → relative → skip chain runs with Windows semantics
49
50
  // against real files, on any host.
50
- export function discoverTestFiles(dir, { path: p = nodePath } = {}) {
51
+ //
52
+ // The walk never opens a folder it skips: the dev server keeps changing what
53
+ // is under .joystack/ (frozen copies come and go) and node_modules/ is large,
54
+ // and listing everything to filter after fails the whole run on a file that
55
+ // vanished mid-listing. A folder that vanishes while the walk looks at it is
56
+ // simply not there. `readdir` is injectable to check both.
57
+ export function discoverTestFiles(dir, { path: p = nodePath, readdir = readdirSync } = {}) {
51
58
  const unit = [];
52
59
  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
- }
60
+ // `real` is the folder as this host's file system names it; `shown`, as the
61
+ // injected path module does (the two differ only under path.win32).
62
+ const walk = (real, shown) => {
63
+ let entries;
64
+ try {
65
+ entries = readdir(real, { withFileTypes: true });
66
+ } catch (err) {
67
+ if (err?.code === 'ENOENT' && real !== dir) return;
68
+ throw err;
69
+ }
70
+ for (const entry of entries) {
71
+ const path = p.join(shown, entry.name);
72
+ if (isSkippedPath(p.relative(dir, path))) continue;
73
+ if (entry.isDirectory()) walk(nodePath.join(real, entry.name), path);
74
+ else if (!entry.isFile()) continue;
75
+ else if (entry.name.endsWith('.test.mjs')) unit.push(path);
76
+ else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
77
+ }
78
+ };
79
+ walk(dir, dir);
62
80
  unit.sort();
63
81
  smokes.sort();
64
82
  return { unit, smokes };
@@ -330,7 +348,7 @@ async function freeTestPort(base = 8100) {
330
348
 
331
349
  // Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
332
350
  // 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;
351
+ export const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
334
352
 
335
353
  // The compact node:test reporter (see test-reporter.mjs), as a file URL for the
336
354
  // same reason.
@@ -343,7 +361,7 @@ const REPORTER = new URL('./test-reporter.mjs', import.meta.url).href;
343
361
  // engine is absent (pruned by an npm install — the next dev/publish self-heals
344
362
  // it) or too old to ship the preload — the spawn then runs without it, as
345
363
  // before.
346
- function joystackTestPreload(projectDir) {
364
+ export function joystackTestPreload(projectDir) {
347
365
  try {
348
366
  const preload = join(resolveEngine(projectDir).dir, 'shared', 'framework', 'build', 'joystack-test-preload.mjs');
349
367
  return existsSync(preload) ? pathToFileURL(preload).href : null;
@@ -378,6 +396,7 @@ export async function testCmd({
378
396
  devFn = dev, lintFn = lintCmd, runFn = run, unitRunFn = run, patterns = [], since, all: runAll = false, list = false,
379
397
  platform = process.platform, smokeTimeoutMs = smokeTimeoutFromEnv(), machineFn = sampleMachine,
380
398
  unitTimeoutMs = timeoutFromEnv('JOYSTACK_TEST_UNIT_TIMEOUT', DEFAULT_UNIT_TIMEOUT_MS),
399
+ ensure = ensureEngine,
381
400
  } = {}) {
382
401
  const project = findProject(cwd);
383
402
  const startedAt = new Date();
@@ -480,6 +499,16 @@ export async function testCmd({
480
499
  log(`▶ this machine was ${Math.round(verdict.machine.cpuBusy * 100)}% busy when this run started (another test run, a build, a game left open?). The result is still valid; the timings are not comparable with a run on an idle machine.`);
481
500
  }
482
501
 
502
+ // The tests, the preload that lets them import the joystack doors, and lint
503
+ // all load the engine, so it has to be the one the game names
504
+ // (engine.mjs, ensureNamedEngine). A run that cannot install it fails here,
505
+ // with a verdict, rather than testing the game on another engine.
506
+ try {
507
+ await ensureNamedEngine(project.dir, { ensure, log });
508
+ } catch (e) {
509
+ throw new Error(namedEngineFailure(project.dir, e));
510
+ }
511
+
483
512
  const { ensureBaked } = await import('./model-bake.mjs');
484
513
  await ensureBaked({ dir: project.dir, log });
485
514
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
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",
@@ -12,7 +12,8 @@
12
12
  "CHANGELOG.md",
13
13
  "bin",
14
14
  "lib",
15
- "!lib/**/*.test.mjs"
15
+ "!lib/**/*.test.mjs",
16
+ "!lib/**/*-kit.mjs"
16
17
  ],
17
18
  "engines": {
18
19
  "node": ">=20.11"