@joystack/cli 0.1.55 → 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.
@@ -65,6 +65,16 @@ function engineAliasPlugin(sharedDir) {
65
65
  }));
66
66
  // Workers-runtime built-ins are provided at execution; never bundle them.
67
67
  build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
68
+ // The bare `three` a trait's view() draws with, and its addons: the
69
+ // engine's vendored copy, the same one the gate (framework/build/
70
+ // expand.js) and the page's import map (inject.mjs) resolve it to. A game
71
+ // folder has no `three` package, so without this a trait file that
72
+ // imports it does not build. Its top level touches no DOM, so it loads
73
+ // in workerd; only rendering, which room code never does, needs a page.
74
+ build.onResolve({ filter: /^three$/ }, () => ({ path: join(sharedDir, 'ui', 'room', 'vendor', 'three.js') }));
75
+ build.onResolve({ filter: /^three\/addons\// }, (args) => ({
76
+ path: join(sharedDir, 'ui', 'room', 'vendor', 'three-addons', args.path.slice('three/addons/'.length)),
77
+ }));
68
78
  },
69
79
  };
70
80
  }
@@ -62,7 +62,7 @@ each picture in the agent's reply.
62
62
  game whose start shows none (a title screen with nothing spawned) fails
63
63
  with "the game did not start".
64
64
  - It opens a page on the game, never the person's own screen. A picture of
65
- what they see comes from Point's "This moment" in the dev tools.
65
+ what they see comes from Pause's "This moment" in the dev tools.
66
66
 
67
67
  ## Files
68
68
 
@@ -0,0 +1,110 @@
1
+ // POST /__joystack/dev/start-failed { message, file, line, column, stack }
2
+ //
3
+ // A start that failed in a browser, which the dev tools runtime posts (the
4
+ // injected tag names this route, inject.mjs), written to the dev server's log
5
+ // as one block. `joystack dev` keeps that log in `.joystack/dev/last.log`
6
+ // (dev-log.mjs), where the game's agent reads it: a failure on a phone, or in
7
+ // the maker's Play, is otherwise seen only by the person holding it.
8
+ //
9
+ // The body is written by a page, which may be anyone's on a shared link: it is
10
+ // taken only from a page the dev server served, size-limited, every field cut
11
+ // and cleaned of control characters, and at most PER_MINUTE are written a
12
+ // minute.
13
+ import { relative, sep } from 'node:path';
14
+ import { canonicalDevPath } from './legacy-names.mjs';
15
+
16
+ export const START_FAILED_PATH = '/__joystack/dev/start-failed';
17
+ const MAX_BYTES = 16 * 1024;
18
+ const PER_MINUTE = 10;
19
+ const STACK_LINES = 30;
20
+ const SHAPE = 'expected { message, file, line, column, stack }';
21
+
22
+ // Text from a page, for one line of the log: no control characters (an
23
+ // escape sequence would move the terminal, a newline would forge a line of
24
+ // the log), no bidirectional or zero-width marks (a line would read one way
25
+ // in a terminal and another to the agent), cut to `max`.
26
+ function logLine(value, max) {
27
+ const s = String(value ?? '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim();
28
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
29
+ }
30
+
31
+ // The block a failed start is written as: what failed, where (the file as it
32
+ // is in the game's folder, or on disk for an engine file), the browser, who
33
+ // sent it, and the stack when there is one, indented under it.
34
+ export function startFailedBlock({ message, file, line, column, stack = [], browser, from }) {
35
+ const place = file ? `${file}${line ? `:${line}${column ? `:${column}` : ''}` : ''}` : '';
36
+ const lines = [`✗ The game didn't start in a browser: ${message || 'unknown error'}`];
37
+ if (place) lines.push(` at ${place}`);
38
+ if (browser) lines.push(` browser: ${browser}`);
39
+ if (from) lines.push(` from: ${from}`);
40
+ for (const l of stack) lines.push(` ${l}`);
41
+ return lines.join('\n');
42
+ }
43
+
44
+ // `log(block)`: where the block goes. `allowed(req)`: whether the request
45
+ // came from a page the dev server served, to the creator. `from(req)`: who
46
+ // sent it, said in the block. `ownHost(host)`: whether a URL's
47
+ // host is the dev server's own. `slug`, `projectDir`, `resolveUrl(urlPath)`:
48
+ // the game served, its folder, and the file a URL serves, to name the file
49
+ // the way the agent finds it.
50
+ export function createStartFailed({ log, allowed, from = () => '', ownHost, slug, projectDir, resolveUrl }) {
51
+ const times = [];
52
+
53
+ // A file the page names, as the agent finds it: a game file by its path in
54
+ // the game's folder (the server may serve a copy of it), a file of the
55
+ // server's otherwise by where it is on disk (from the game's folder when
56
+ // inside it), anything else as it was given.
57
+ function placeOnDisk(file) {
58
+ if (!file) return '';
59
+ let urlPath;
60
+ try {
61
+ const url = new URL(file, 'http://x');
62
+ if (!file.startsWith('/') && !ownHost(url.host)) return file;
63
+ urlPath = canonicalDevPath(decodeURIComponent(url.pathname));
64
+ } catch { return file; }
65
+ const inGame = `/games/${slug}/`;
66
+ if (urlPath.startsWith(inGame)) return urlPath.slice(inGame.length);
67
+ const fsPath = resolveUrl(urlPath);
68
+ if (!fsPath) return urlPath;
69
+ const rel = projectDir ? relative(projectDir, fsPath) : '';
70
+ return (rel && !rel.startsWith('..') && !/^[A-Za-z]:/.test(rel) ? rel : fsPath).split(sep).join('/');
71
+ }
72
+
73
+ // Whether another block may be written now.
74
+ function room() {
75
+ const now = Date.now();
76
+ while (times.length && now - times[0] > 60_000) times.shift();
77
+ if (times.length >= PER_MINUTE) return false;
78
+ times.push(now);
79
+ return true;
80
+ }
81
+
82
+ return async function handle(req, res) {
83
+ const answer = (status, body) => {
84
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(body ? {} : { 'Content-Length': 0 }) });
85
+ res.end(body ? JSON.stringify(body) : undefined);
86
+ };
87
+ if (req.method !== 'POST') return answer(405, { error: 'POST only' });
88
+ if (!allowed(req)) return answer(403, { error: 'only from a page this server served' });
89
+ let text = '';
90
+ for await (const c of req) {
91
+ text += c;
92
+ if (text.length > MAX_BYTES) return answer(413, { error: 'body too large' });
93
+ }
94
+ let body;
95
+ try { body = JSON.parse(text); } catch { return answer(400, { error: SHAPE }); }
96
+ if (!body || typeof body !== 'object') return answer(400, { error: SHAPE });
97
+ if (!room()) return answer(204);
98
+ const count = (v) => (Number.isInteger(v) && v > 0 && v < 1e7 ? v : 0);
99
+ log(startFailedBlock({
100
+ message: logLine(body.message, 500),
101
+ file: placeOnDisk(logLine(body.file, 500)),
102
+ line: count(body.line),
103
+ column: count(body.column),
104
+ stack: String(body.stack ?? '').split('\n').map((l) => logLine(l, 300)).filter(Boolean).slice(0, STACK_LINES),
105
+ browser: logLine(req.headers['user-agent'], 200),
106
+ from: from(req),
107
+ }));
108
+ return answer(204);
109
+ };
110
+ }
@@ -19,8 +19,10 @@ import { extname, join, normalize, relative, sep } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { gunzipSync } from 'node:zlib';
21
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';
22
+ import { cacheHeaders, closureStamps, declaredAssets, freshOfRequest, freshToken, notModified, splitStamp, stampValue, startFiles, WARM_PAGE, WARM_PATH } from './fingerprint/fingerprint.mjs';
23
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';
24
26
  import { WHOAMI_PATH } from './ports.mjs';
25
27
  import {
26
28
  writeSegment, stitchStream, streamDir, listRecordings, latestRecording, deleteRecording, setKept,
@@ -29,6 +31,7 @@ import {
29
31
  import { gameBuild } from './build-id.mjs';
30
32
  import { createHistory, checkPath, HISTORY_PATH, HistoryError } from './history.mjs';
31
33
  import { STATE_DIR_NAMES, canonicalDevPath, stateDir, stateDirName, withLegacyImportMap } from './legacy-names.mjs';
34
+ import { reloadClient } from './reload-client.mjs';
32
35
 
33
36
  // `?replay=<id>` resolves to `.joystack/replays/<id>.json` (replaySourceUrl in the
34
37
  // engine's replay transport). Nothing writes that FILE — the room writes the
@@ -52,51 +55,6 @@ const REPLAY_URL_RE = new RegExp(`^/games/[^/]+/(?:${STATE_DIRS_RE})/replays/([a
52
55
  // one of them carries a dash and none of them is a word.
53
56
  const LATEST_ALIAS = 'last';
54
57
 
55
- // The page's half of /__reload, under `joystack dev` (live-versions.mjs). Every
56
- // page the server serves belongs to one version of the game, and leaves when
57
- // that version stops being the one served:
58
- //
59
- // game a game page. It reloads as soon as an update starts, so no game
60
- // runs while files are replaced; the server answers it with the
61
- // "Updating…" page, then the game.
62
- // status the "Updating…" or error page. It reloads when a version is ready.
63
- // other any other page (the Assets page). It reloads once the new version
64
- // is served.
65
- //
66
- // The stream sends `reload` after every switch, and the status (JSON) on
67
- // connecting and whenever it changes. A page whose version is not the one in
68
- // the status reloads at once: it was served just before a switch.
69
- const reloadClient = (kind, version, token) => `<script>
70
- (() => {
71
- const kind = ${JSON.stringify(kind)};
72
- const version = ${JSON.stringify(version)};
73
- let alive = false;
74
- let leaving = false;
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); } };
79
- function connect() {
80
- const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
81
- es.onopen = () => { alive = true; };
82
- es.onmessage = (e) => {
83
- if (e.data === 'reload') return leave();
84
- let s;
85
- try { s = JSON.parse(e.data); } catch { return; }
86
- if (kind === 'game' && (s.state !== 'ready' || s.version !== version)) return leave();
87
- if (kind === 'status' && s.state === 'ready') return leave();
88
- if (kind === 'status') window.dispatchEvent(new CustomEvent('joystack-status', { detail: s }));
89
- };
90
- es.onerror = () => {
91
- es.close();
92
- setTimeout(connect, alive ? 200 : 1000);
93
- alive = false;
94
- };
95
- }
96
- connect();
97
- })();
98
- </script>`;
99
-
100
58
  // The one line of a failed build a person needs: the first error it reports,
101
59
  // with the game's paths written from the game folder and the build's own
102
60
  // location left off. The whole error stays in the terminal and in
@@ -220,8 +178,9 @@ function trustedHost(header, shareHost = null) {
220
178
  // gives an attacker both halves at once (see trustedHost).
221
179
  //
222
180
  // `requireOrigin` turns the absent-header case from pass into fail, and is what
223
- // separates "a page this server served" from "any program that can reach this
224
- // 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
225
184
  // HEAD, same-origin ones included — so on the MUTATING verbs the header is free
226
185
  // to demand and refuses every non-browser caller. A GET cannot demand it: a
227
186
  // same-origin GET carries no Origin either, so the game page fetching its own
@@ -252,6 +211,12 @@ function devtoolsRide({ shared, shareIsOwner, carried }) {
252
211
  return carried && (!shared || shareIsOwner);
253
212
  }
254
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
+
255
220
  // Whether a Host header names the shared tunnel hostname (with or without a port).
256
221
  function isShareHost(header, shareHost) {
257
222
  return !!shareHost && String(header ?? '').replace(/:\d+$/, '').toLowerCase() === shareHost;
@@ -265,34 +230,6 @@ function shareGuestIdentity() {
265
230
  return { userId: `dev-share-${id}`, name: `guest-${id.slice(0, 4)}`, color: '#a3e635' };
266
231
  }
267
232
 
268
- const MIME = {
269
- '.html': 'text/html; charset=utf-8',
270
- '.js': 'text/javascript; charset=utf-8',
271
- '.mjs': 'text/javascript; charset=utf-8',
272
- '.css': 'text/css; charset=utf-8',
273
- '.json': 'application/json; charset=utf-8',
274
- '.svg': 'image/svg+xml',
275
- '.png': 'image/png',
276
- '.jpg': 'image/jpeg',
277
- '.jpeg': 'image/jpeg',
278
- '.gif': 'image/gif',
279
- '.webp': 'image/webp',
280
- '.ico': 'image/x-icon',
281
- '.wasm': 'application/wasm',
282
- '.mp3': 'audio/mpeg',
283
- '.ogg': 'audio/ogg',
284
- '.wav': 'audio/wav',
285
- '.m4a': 'audio/mp4',
286
- '.flac': 'audio/flac',
287
- '.txt': 'text/plain; charset=utf-8',
288
- '.md': 'text/markdown; charset=utf-8',
289
- '.woff': 'font/woff',
290
- '.woff2': 'font/woff2',
291
- '.glb': 'model/gltf-binary',
292
- '.gltf': 'model/gltf+json',
293
- '.bin': 'application/octet-stream',
294
- };
295
-
296
233
  export function createStaticServer({
297
234
  slug,
298
235
  mounts,
@@ -324,8 +261,10 @@ export function createStaticServer({
324
261
  // this machine wakes that session, and the note rides on every page.
325
262
  agentSession = null,
326
263
  // Whether a request through the share link is the creator's own (startDev's
327
- // `shareIsOwner`): its relay events then count as from this machine.
264
+ // `shareIsOwner`): its relay requests then count as from this machine.
328
265
  shareIsOwner = false,
266
+ // The relay's feed for Claude Code (relay/relay.mjs); off in the maker.
267
+ agentFeed = true,
329
268
  }) {
330
269
  // The tunnel hostname `joystack dev --share` opened to this server, once it is
331
270
  // up (setShareHost). See trustedHost.
@@ -337,13 +276,12 @@ export function createStaticServer({
337
276
  // The creator's own browser: on this machine, or through a share link that
338
277
  // only the creator can reach the dev tools by.
339
278
  const fromCreator = (req) => fromThisMachine(req) || (shareIsOwner && isShareHost(req.headers.host, shareHost));
340
- // The relay's hub (relay/relay.mjs): events between the pages this server
341
- // serves and the agent. Without a projectDir there is no game to relay for
342
- // 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.
343
282
  const relay = projectDir ? createRelay({
344
- projectDir, agentSession, log, cliBin: CLI_BIN,
345
- sameOrigin: (req) => sameOrigin(req, { shareHost }),
346
- fromCreator,
283
+ projectDir, agentSession, log, cliBin: CLI_BIN, sharedDir: () => engineSharedDir, feed: agentFeed,
284
+ sameOrigin: (req, opts) => sameOrigin(req, { ...opts, shareHost }), fromCreator,
347
285
  }) : null;
348
286
  // The laptop note (NOTE_URL) rides on every page the creator opens, when
349
287
  // the coding-agent session that started this server is there to be woken.
@@ -373,7 +311,7 @@ export function createStaticServer({
373
311
  }
374
312
  // The state folder (`.joystack/`) is the game's private working directory, not part of the site.
375
313
  // It holds the relay's files (the notes the creator sent the agent, with
376
- // 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
377
315
  // non-replicated field, and every participant's account id — strictly more
378
316
  // than any single live client is ever sent). This server binds every
379
317
  // interface and stamps `Access-Control-Allow-Origin: *`, so serving that
@@ -477,10 +415,16 @@ export function createStaticServer({
477
415
  const status = versions?.status();
478
416
  if (gamePage && status && status.state !== 'ready') return sendStatus(res, status);
479
417
  let html = pageHtml(req, fsPath, urlPath, query);
480
- if (wantsNote(req)) html = injectNoteTag(html);
481
- if (versions) {
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) {
482
426
  let token = 0;
483
- if (gamePage) {
427
+ if (versions && gamePage) {
484
428
  token = nextToken++;
485
429
  gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
486
430
  }
@@ -491,9 +435,18 @@ export function createStaticServer({
491
435
  }
492
436
 
493
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
+
494
446
  function pageHtml(req, fsPath, urlPath, query) {
447
+ const fresh = freshToken(query?.get('fresh'));
495
448
  let html = readFileSync(fsPath, 'utf8');
496
- html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
449
+ html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl, fresh });
497
450
  html = withLegacyImportMap(html);
498
451
  // Mirror the production /g/<slug> entry injection — only when the resolved
499
452
  // engine actually ships the platform layer (transition shim, same as
@@ -524,14 +477,15 @@ export function createStaticServer({
524
477
  // and earlier); a newer engine has the dev tools runtime instead.
525
478
  // Per-request, so a bundle swap mid-session is honoured.
526
479
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
527
- const stamps = closureStamps(resolveUrl);
528
- const stamp = (url) => { const v = stamps.stamp(url); return v ? `${url}?v=${v}` : url; };
480
+ const stamp = stamper(fresh);
529
481
  html = injectHeadTags(html, m[1], {
530
482
  stamp,
531
483
  ...(tabIdentity ? { identity: tabIdentity } : {}),
532
484
  toolbox,
533
485
  ...devtoolsFor(shared, m[1]),
534
486
  skeleton: currentSkeleton && stampedKinds(currentSkeleton, urlPath, stamp),
487
+ fresh,
488
+ report: START_FAILED_PATH,
535
489
  ...(shared ? { shareHost, replay: shareIsOwner } : {}),
536
490
  });
537
491
  }
@@ -566,11 +520,14 @@ export function createStaticServer({
566
520
  return sendBody(res, 200, JSON.stringify({ files }), 'application/json');
567
521
  }
568
522
 
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/).
569
525
  function serveJs(req, res, fsPath, urlPath, query) {
570
526
  const raw = readFileSync(fsPath);
527
+ const fresh = splitStamp(query?.get('v')).fresh ?? (query?.has('v') ? null : freshOfRequest(req, query));
571
528
  let body;
572
529
  try {
573
- body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl });
530
+ body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl, fresh });
574
531
  } catch {
575
532
  body = raw;
576
533
  }
@@ -582,7 +539,7 @@ export function createStaticServer({
582
539
  // server by its tag (fingerprint/).
583
540
  function sendFile(req, res, body, type, urlPath, query) {
584
541
  const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
585
- const requested = query?.get('v') ?? null;
542
+ const requested = splitStamp(query?.get('v') ?? null).stamp;
586
543
  const headers = cacheHeaders({ requested, current: requested ? closureStamps(resolveUrl).stamp(urlPath) : null, body: buf });
587
544
  if (headers.ETag && notModified(req, headers.ETag)) {
588
545
  // Closed after: the maker's container proxy (workerd) fails the next
@@ -611,10 +568,12 @@ export function createStaticServer({
611
568
  // Only a page of this server's own may frame one of its pages. The dev
612
569
  // tools runtime (shared/ui/devtools/runtime.js) takes the page that
613
570
  // 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
615
- // live links from its own origin, which its Worker names in place of
616
- // this on every page it passes on.
617
- ...(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) } : {}),
618
577
  });
619
578
  res.end(buf);
620
579
  }
@@ -889,7 +848,17 @@ export function createStaticServer({
889
848
  return json(200, { stream, deleted: true });
890
849
  }
891
850
 
892
- 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) {
893
862
  res.writeHead(200, {
894
863
  'Content-Type': 'text/event-stream',
895
864
  'Cache-Control': 'no-cache',
@@ -905,8 +874,12 @@ export function createStaticServer({
905
874
  keepalive.unref();
906
875
  reload.on('reload', push);
907
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
+ : () => {};
908
880
  res.on('close', () => {
909
881
  clearInterval(keepalive);
882
+ stopRelay();
910
883
  reload.off('reload', push);
911
884
  reload.off('status', pushStatus);
912
885
  if (page) {
@@ -962,6 +935,21 @@ export function createStaticServer({
962
935
  return json(404, { error: 'not found' });
963
936
  }
964
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
+
965
953
  // ── model history: the game's files at an earlier commit ────────────────
966
954
  //
967
955
  // GET /__joystack/history?path=<p>&path=<p>… the commits that touched those
@@ -1063,10 +1051,13 @@ export function createStaticServer({
1063
1051
  const server = http.createServer((req, res) => {
1064
1052
  const reqUrl = new URL(req.url, 'http://x');
1065
1053
  const urlPath = canonicalDevPath(reqUrl.pathname);
1066
- if (urlPath === '/__reload') return handleSse(res, reqUrl.searchParams);
1054
+ if (urlPath === '/__reload') return handleSse(req, res, reqUrl.searchParams);
1067
1055
  if (urlPath.startsWith(`${WARM_PATH}/`)) {
1068
1056
  return serveWarm(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1069
1057
  }
1058
+ if (urlPath === START_FAILED_PATH) {
1059
+ return handleStartFailed(req, res).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1060
+ }
1070
1061
  if (versions && (urlPath === DEV_CONTROL_PATH || urlPath.startsWith(`${DEV_CONTROL_PATH}/`))) {
1071
1062
  return handleDevControl(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
1072
1063
  }
@@ -1140,7 +1131,8 @@ export function createStaticServer({
1140
1131
  }
1141
1132
 
1142
1133
  if (urlPath === '/' || urlPath === '/index.html') {
1143
- 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 });
1144
1136
  return res.end();
1145
1137
  }
1146
1138
  let fsPath = resolveUrl(urlPath);
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';
@@ -395,6 +396,7 @@ export async function testCmd({
395
396
  devFn = dev, lintFn = lintCmd, runFn = run, unitRunFn = run, patterns = [], since, all: runAll = false, list = false,
396
397
  platform = process.platform, smokeTimeoutMs = smokeTimeoutFromEnv(), machineFn = sampleMachine,
397
398
  unitTimeoutMs = timeoutFromEnv('JOYSTACK_TEST_UNIT_TIMEOUT', DEFAULT_UNIT_TIMEOUT_MS),
399
+ ensure = ensureEngine,
398
400
  } = {}) {
399
401
  const project = findProject(cwd);
400
402
  const startedAt = new Date();
@@ -497,6 +499,16 @@ export async function testCmd({
497
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.`);
498
500
  }
499
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
+
500
512
  const { ensureBaked } = await import('./model-bake.mjs');
501
513
  await ensureBaked({ dir: project.dir, log });
502
514
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.55",
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"