@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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,44 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.56] - 2026-10-22
18
+
19
+ ### Added
20
+
21
+ - `joystack relay reply <note id> <text>` answers a note the creator sent
22
+ you from the running game; the wake message prints the exact command. The
23
+ answer shows under their note even when your edit reloaded the page
24
+ before you replied, or the dev server restarted.
25
+ - `joystack dev` writes a start that failed in any browser playing your game, a phone on your wifi included, to `.joystack/dev/last.log` as one block naming the file and line.
26
+ - `joystack dev` serves a page opened with `?fresh=<token>` with every file under a new address, so nothing of it comes from the browser's cache; the failed-start card's Reload opens the game so.
27
+
28
+ ### Changed
29
+
30
+ - `joystack dev` needs engine 0.2.49 or later for the note, picks and
31
+ replies: on an older engine they do nothing until `npx joystack update`.
32
+ - `joystack dev` runs the engine's rebuilt relay, on one route. Each pick
33
+ the creator makes in the game or on the Assets page is a line in
34
+ `.joystack/dev/last.log` for you, and a page can ask whether this server
35
+ takes picks. Nothing a phone on the wifi or a `--share` tester does on
36
+ the game's page reaches you: the dev server refuses it.
37
+ - A page `joystack dev` serves may be framed by the same server under its
38
+ other loopback name (`localhost` and `127.0.0.1`, on the same port), and
39
+ still by no other site.
40
+ - `joystack assets` asks the running `joystack dev` who a note would wake
41
+ over the same relay as the page.
42
+ - In the Joystack maker, the line `joystack assets <id> --export` prints for a `.glb` that left something out now carries it as a title, `![Car](….glb "simplified copy: …")`; the chat marks that model a simplified copy and says what was left out when it is tapped. Copy the line as printed.
43
+
44
+ ### Removed
45
+
46
+ - `joystack relay publish` and `joystack relay tail`. Answer a note with
47
+ `joystack relay reply <note id> <text>`, the command its wake message
48
+ prints, and read what the creator picked in `.joystack/dev/last.log`.
49
+
50
+ ### Fixed
51
+
52
+ - A trait file that imports `three` (for its `view()`) now builds: `joystack dev` and `joystack publish` used to fail with `Could not resolve "three"`, and the page showed "The game has an error". The room now gets the same copy of three as the page.
53
+ - `joystack lint` and `joystack test` check your game on the engine its `package.json` names, installing it first as `joystack dev` does. After a merge that changed the engine, they used to check it on the engine still installed and could pass it on code it no longer runs. If that engine cannot be installed (no network, and never downloaded on this machine), they fail and say so rather than checking on another engine.
54
+
17
55
  ## [0.1.55] - 2026-10-22
18
56
 
19
57
  ### Added
package/bin/joystack.mjs CHANGED
@@ -62,8 +62,7 @@ Usage:
62
62
  joystack replay profile [<id>] --ticks <a-b>
63
63
  Which functions ran in those ticks, as a table
64
64
  joystack feedback Send reports + replies under notes/feedback/; pull outcomes back in
65
- joystack relay publish <name> [<json>] Put an event on the running game's relay, as the agent (agent.replied …)
66
- joystack relay tail [<pattern>…] Print the running game's relay events as they happen, one JSON line each
65
+ joystack relay reply <note id> <text> Answer a note the creator sent you from the running game
67
66
  joystack login Authenticate this machine as your Joystack player account
68
67
  joystack logout [--all] Sign out: revoke the token (--all: on every machine)
69
68
  joystack whoami Show who this machine publishes as
@@ -29,8 +29,12 @@ joystack assets --export <file.js> [--stage <stage>] [--set <k=v,…>] [--tag <n
29
29
 
30
30
  Exports one asset as files: a 3D model as <name>.glb and a picture of it
31
31
  <name>.png, a sound as <name>.wav, any other asset as <name>.png. Written to
32
- .joystack/exports/ unless --out names a folder. Says what a .glb could not
33
- carry (a look drawn by a shader of the asset's own, motion made in code).
32
+ .joystack/exports/ unless --out names a folder. An outline drawn as a
33
+ back-face hull and toon paint are converted so the .glb looks the same in
34
+ any glTF viewer; what it still cannot carry (a shader of the asset's own,
35
+ particles, reflections, glows, motion made in code, …) is named on a
36
+ "left out:" line, and in the maker the reply line marks the model a
37
+ simplified copy.
34
38
 
35
39
  <file.js> a scratch module: a file in the asset format that is not
36
40
  declared in assets.js, exported as it is (its stage is
@@ -96,10 +100,18 @@ export function scratchPath(projectDir, file, cwd) {
96
100
  // markdown image of the model's .glb (its picture is found beside it), the
97
101
  // sound or the picture. The path is written with forward slashes, since a
98
102
  // backslash in markdown escapes the character after it.
99
- export function replyLine(label, path) {
103
+ //
104
+ // A .glb that leaves something out (`left`, the export's sentences) carries
105
+ // them as the image's title, `"simplified copy: …"`, and the maker's chat
106
+ // marks the model so, with what was left out on a tap, one item per
107
+ // sentence: the chat splits the title on ";", so a sentence's own becomes a
108
+ // comma. A model with nothing left out has no title.
109
+ export function replyLine(label, path, left = []) {
100
110
  const alt = String(label).replace(/[[\]\n]/g, ' ').trim() || 'Asset';
101
111
  path = path.split(sep).join('/');
102
- return `![${alt}](${/[\s()<>]/.test(path) ? `<${path}>` : path})`;
112
+ const where = /[\s()<>]/.test(path) ? `<${path}>` : path;
113
+ const said = /\.glb$/i.test(path) ? left.map((l) => String(l).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/"/g, "'").replace(/;/g, ',').replace(/\s+/g, ' ').trim()).filter(Boolean) : [];
114
+ return `![${alt}](${where}${said.length ? ` "simplified copy: ${said.join('; ')}"` : ''})`;
103
115
  }
104
116
 
105
117
  const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
@@ -146,7 +158,7 @@ function report({ files, made, label, projectDir, errors, env, log }) {
146
158
  for (const l of made.left ?? []) log(` left out: ${printable(l)}`);
147
159
  if (made.clips?.length) log(` clips: ${made.clips.map(printable).join(', ')}`);
148
160
  if (errors.length) log(` ⚠ the page reported: ${printable(errors.join('; '))}`);
149
- if (makerDraft(env)) log(` To show it in your reply, write: ${replyLine(label, shown(main))}`);
161
+ if (makerDraft(env)) log(` To show it in your reply, write: ${replyLine(label, shown(main), main === files.glb ? made.left ?? [] : [])}`);
150
162
  }
151
163
 
152
164
  // The game's Assets page driver, or the one a test gives, refused when the
@@ -165,31 +165,36 @@ export async function openAssets({
165
165
  }
166
166
 
167
167
  // The agent a running stack would wake (`'Claude Code'`), or null when none
168
- // is listening or the stack cannot say. Loopback only, with the header the
169
- // status route requires.
170
- function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
171
- return loopbackJson({ port, path: '/__joystack/relay/status', headers: { 'X-Joystack-Agent-UI': '1' }, timeoutMs })
172
- .then((body) => body?.agent ?? null);
168
+ // is listening or the stack cannot say: the relay's `server.agent`
169
+ // (relay/relay.mjs), asked on loopback with the origin of a page the stack
170
+ // served, which is what it takes from a program on this machine.
171
+ export function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
172
+ return loopbackJson({
173
+ port, path: '/__joystack/relay', timeoutMs,
174
+ headers: { Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({ name: 'server.agent' }),
176
+ }).then((out) => (out?.ok ? out.value ?? null : null));
173
177
  }
174
178
 
175
179
  // What a route of a dev stack on this machine answers, as JSON, or null when
176
180
  // it answers anything but a 200 with JSON, or nothing in `timeoutMs`. Asked of
177
181
  // 127.0.0.1 under its own Host, which is what the stack's same-origin checks
178
- // accept.
179
- export function loopbackJson({ port, path, headers = {}, timeoutMs = HEALTH_TIMEOUT_MS }) {
182
+ // accept: a GET, or a POST of `body`.
183
+ export function loopbackJson({ port, path, headers = {}, body = null, timeoutMs = HEALTH_TIMEOUT_MS }) {
180
184
  return new Promise((resolve) => {
181
- const req = http.get({
182
- host: '127.0.0.1', port, path, timeout: timeoutMs,
185
+ const req = http.request({
186
+ host: '127.0.0.1', port, path, method: body === null ? 'GET' : 'POST', timeout: timeoutMs,
183
187
  headers: { Host: `127.0.0.1:${port}`, ...headers },
184
188
  }, (res) => {
185
- let body = '';
189
+ let text = '';
186
190
  res.setEncoding('utf8');
187
- res.on('data', (d) => { if (body.length < 65536) body += d; });
191
+ res.on('data', (d) => { if (text.length < 65536) text += d; });
188
192
  res.on('end', () => {
189
- try { resolve(res.statusCode === 200 ? JSON.parse(body) : null); } catch { resolve(null); }
193
+ try { resolve(res.statusCode === 200 ? JSON.parse(text) : null); } catch { resolve(null); }
190
194
  });
191
195
  });
192
196
  req.on('timeout', () => { req.destroy(); resolve(null); });
193
197
  req.on('error', () => resolve(null));
198
+ req.end(body ?? undefined);
194
199
  });
195
200
  }
package/lib/dev.mjs CHANGED
@@ -115,14 +115,18 @@ export async function dev(opts = {}) {
115
115
  //
116
116
  // `shareIsOwner` is for a host that serves the share link to the creator
117
117
  // alone and keeps everyone else off the dev tools' routes (the Joystack
118
- // maker): a relay event sent through the link then counts as one from this
118
+ // maker): a relay request sent through the link then counts as one from this
119
119
  // machine, so it can wake the agent and read its reply.
120
120
  //
121
+ // `agentFeed: false` turns off the relay's feed for Claude Code (the lines
122
+ // relay/relay.mjs writes to the log, and `server.picks`), for a host whose
123
+ // page takes the picks itself (the Joystack maker).
124
+ //
121
125
  // `pollMs` is how often, in milliseconds, the game's files are compared for
122
126
  // changes the operating system's events miss (live-versions.mjs); left unset
123
127
  // it is that module's default of one second. Tests pass a shorter one so a
124
128
  // save is acted on sooner.
125
- async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, shareIsOwner = false, devLogPath = null, pollMs, announce = true } = {}, onFailure) {
129
+ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, shareIsOwner = false, agentFeed = true, devLogPath = null, pollMs, announce = true } = {}, onFailure) {
126
130
  const project = findProject(cwd);
127
131
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
128
132
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -335,6 +339,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
335
339
  engineVersion: engine.version ?? null, // stamped on recorded sessions
336
340
  agentSession, // woken by a note sent on the relay from this machine
337
341
  shareIsOwner, // and by one sent through the share link, when it is the creator's alone
342
+ agentFeed, // the relay's feed for Claude Code, off where the page's host takes the picks itself (the maker)
338
343
  mounts: mountsFor(codeDir),
339
344
  ...(first ? { fixedBuild: first.build } : {}),
340
345
  versions: live ? {
package/lib/engine.mjs CHANGED
@@ -76,8 +76,9 @@ function npmInstallTarball(projectDir, tgzPath) {
76
76
  *
77
77
  * The moment those doors are wanted is the moment somebody runs a test file by
78
78
  * hand, which is not a moment any CLI command is watching — so they are left
79
- * behind in advance, by whatever ran last. `create` and `dev` come through
80
- * here; `joystack test` does not, and writes them itself (test-cmd.mjs).
79
+ * behind in advance, by whatever ran last. `create`, `dev`, `lint` and `test`
80
+ * come through here (the checks by ensureNamedEngine); `joystack test` also
81
+ * writes them itself (test-cmd.mjs).
81
82
  */
82
83
  export async function ensureEngine(projectDir, opts = {}) {
83
84
  const engine = await installEngine(projectDir, opts);
@@ -85,6 +86,38 @@ export async function ensureEngine(projectDir, opts = {}) {
85
86
  return engine;
86
87
  }
87
88
 
89
+ /**
90
+ * The engine a check runs on (`joystack lint`, `joystack test`): the one the
91
+ * game's package.json names, installed first when node_modules holds another.
92
+ * `git merge` of an engine update rewrites the `joystack.engine` pin and leaves
93
+ * node_modules, which git ignores, on the old engine; a check that resolved the
94
+ * installed copy would pass the game on code it no longer runs.
95
+ *
96
+ * It costs two small file reads when the named engine is already installed.
97
+ * Otherwise it is `ensureEngine`: it installs from the download cache
98
+ * (`~/.joystack/cache`) when this machine downloaded that release before, and
99
+ * needs the network and a sign-in only for a release it never had. A game with
100
+ * no pin is left on what is installed, and null is returned: a check never
101
+ * picks an engine for a game, `create` and `dev` do. Throws, with the reason,
102
+ * when the named engine cannot be installed.
103
+ */
104
+ export async function ensureNamedEngine(projectDir, { ensure = ensureEngine, log = console.log } = {}) {
105
+ if (!readEnginePin(projectDir)) return null;
106
+ // A check runs unattended (an agent's lint or test): it never opens the
107
+ // sign-in flow and waits on a person, as `dev` does.
108
+ const loginFn = async () => { throw new Error('this machine is not signed in to Joystack: run `npx joystack login`, or `npx joystack dev`, which signs in and installs it'); };
109
+ return ensure(projectDir, { log, loginFn });
110
+ }
111
+
112
+ // The line a check prints when the engine its game names could not be
113
+ // installed, so that neither lint nor test passes a game on another engine.
114
+ export function namedEngineFailure(projectDir, error) {
115
+ let installed = null;
116
+ try { installed = resolveEngine(projectDir).version; } catch { /* none installed */ }
117
+ return `your game names engine ${readEnginePin(projectDir)} in package.json, and it could not be installed `
118
+ + `(${installed ? `this game has ${installed}` : 'no engine is installed'}): ${error?.message ?? error}`;
119
+ }
120
+
88
121
  async function installEngine(
89
122
  projectDir,
90
123
  {
@@ -6,6 +6,12 @@ A file's address from its content, for the dev server (`static-server.mjs`).
6
6
  import-map target, an injected tag) carries `?v=<hash>`, the hash of the
7
7
  file's import closure, so an edit anywhere below a module renames it, and a
8
8
  restart of the dev server renames nothing.
9
+ - **Fresh addresses**: a page loaded with `?fresh=<token>` names every
10
+ stamp `?v=<hash>.<token>`, and every module it reaches names its imports
11
+ the same way (a module asked for by its plain URL takes the token from its
12
+ own `?fresh=` or from its referrer). Nothing of such a page comes from the
13
+ browser's cache; a failed start's Reload loads the page so. A page without
14
+ it is named as always.
9
15
  - **The headers**: a stamped URL whose `v` is current is kept by the
10
16
  browser for good; any other file of the game or the engine is checked with the server (`ETag`, `304`).
11
17
  A game's page is never kept.
@@ -134,6 +134,43 @@ export function closureStamps(resolveUrl) {
134
134
  };
135
135
  }
136
136
 
137
+ // A fresh token: a page loaded with `?fresh=<n>` names every stamp it
138
+ // carries `?v=<stamp>.<n>`, and so does every module it reaches, so none of
139
+ // its files is taken from the browser's cache. A failed start's Reload loads
140
+ // the page so: a damaged copy kept for good under a stamp cannot be what the
141
+ // next start runs. Letters and digits only, since it is written into pages
142
+ // and modules.
143
+ const FRESH_RE = /^[0-9a-z]{1,16}$/;
144
+ export const freshToken = (value) => (typeof value === 'string' && FRESH_RE.test(value) ? value : null);
145
+
146
+ // A `?v=` value as the stamp it asks for and its fresh token, if any.
147
+ export function splitStamp(value) {
148
+ if (value == null) return { stamp: null, fresh: null };
149
+ const m = /^([0-9a-f]{12})\.([0-9a-z]{1,16})$/.exec(value);
150
+ return m ? { stamp: m[1], fresh: m[2] } : { stamp: value, fresh: null };
151
+ }
152
+
153
+ // A URL's `?v=` value: its stamp, then the fresh token when there is one.
154
+ export const stampValue = (stamp, fresh = null) => (fresh ? `${stamp}.${fresh}` : stamp);
155
+
156
+ // The fresh token of a request for a module asked for by its plain URL (the
157
+ // dev tools' tool files, a computed `import()`): its own `?fresh=`, or else
158
+ // the one its referrer carries, a module of a fresh page or the page itself,
159
+ // on this same host. A browser sends the importing module's URL as a module
160
+ // request's referrer, so a module reached both ways runs once, under fresh
161
+ // addresses, and not twice. A page whose referrer policy keeps the query to
162
+ // itself loses only this fallback.
163
+ export function freshOfRequest(req, query) {
164
+ const own = freshToken(query?.get('fresh'));
165
+ if (own) return own;
166
+ const referer = req?.headers?.referer;
167
+ if (!referer) return null;
168
+ let url;
169
+ try { url = new URL(referer); } catch { return null; }
170
+ if (url.host !== String(req.headers.host ?? '')) return null;
171
+ return freshToken(url.searchParams.get('fresh')) ?? splitStamp(url.searchParams.get('v')).fresh;
172
+ }
173
+
137
174
  // A stamped address in a page: in quotes, ending in `?v=<stamp>`.
138
175
  const STAMPED_RE = /["']([^"'\s<>]+)\?v=[0-9a-f]{12}["']/g;
139
176
 
@@ -175,7 +212,10 @@ export function startFiles({ html, pageUrl, resolveUrl, plain = [] }) {
175
212
  // is the stamp the URL carries (`?v=`), `current` the file's stamp now. Only
176
213
  // a URL whose stamp is current is kept for good: an older page asking for an
177
214
  // address whose file has since changed gets the new bytes, which must not be
178
- // kept under the old address. Everything else carries a tag of its bytes and
215
+ // kept under the old address. A fresh address (`requested` without its
216
+ // token, splitStamp) of the current bytes is kept the same way: a damaged
217
+ // copy under it lasts until the next Reload picks another token. Everything
218
+ // else carries a tag of its bytes and
179
219
  // is checked with the server each time, a round trip and no download when it
180
220
  // has not changed. `private`: a maker live link serves only its owner.
181
221
  export function cacheHeaders({ requested, current, body }) {
package/lib/inject.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // it and an unchanged file keeps its address, and with it the browser's
13
13
  // copy.
14
14
  import { dirname } from 'node:path';
15
- import { closureStamps, JS_IMPORT_RE, resolveSpecToUrl } from './fingerprint/fingerprint.mjs';
15
+ import { closureStamps, JS_IMPORT_RE, resolveSpecToUrl, stampValue } from './fingerprint/fingerprint.mjs';
16
16
  import { LEGACY_PAGE_GLOBALS, withLegacyDoors } from './legacy-names.mjs';
17
17
 
18
18
  // The identity every `joystack dev` page is signed in as.
@@ -107,15 +107,18 @@ function importMap(v2, tools, stamp) {
107
107
  }
108
108
 
109
109
  // The runtime's tag: the overlay's and Point's doors as lazy imports, and
110
- // the tool files. `point`: the engine has Point. `shared`: the page is
111
- // reached through a share link (the maker's live link), where Point is the
112
- // maker's only: opened alone, nobody hears a pick.
110
+ // the tool files. `point`: the engine has Point. Whether a pick is taken is
111
+ // the host's to say (the overlay asks this server, `server.picks`).
113
112
  // The tool files keep their plain URLs: the runtime reads a tool's name from
114
113
  // its file's (shared/ui/devtools/game-tools.js), and a stamp would end it.
115
- function devtoolsTag(engineTools, gameTools, { point = false, shared = false, stamp = (url) => url } = {}) {
116
- const pointing = point ? `, point: () => import("${stamp(POINT_URL)}")${shared ? ', pointAlone: false' : ''}` : '';
114
+ // `fresh`: the page's fresh token (fingerprint/), which the runtime adds to
115
+ // each tool file's URL as `?fresh=` when it imports it. `report`: where the
116
+ // runtime posts a failed start (static-server.mjs, START_FAILED_PATH).
117
+ function devtoolsTag(engineTools, gameTools, { point = false, stamp = (url) => url, fresh = null, report = null } = {}) {
118
+ const pointing = point ? `, point: () => import("${stamp(POINT_URL)}")` : '';
117
119
  const files = `${engineTools.length ? `, engineTools: ${jsJson(engineTools)}` : ''}${gameTools.length ? `, gameTools: ${jsJson(gameTools)}` : ''}`;
118
- return `<script type="module">import { startDevtools } from "${stamp(DEVTOOLS_URL)}"; startDevtools({ overlay: () => import("${stamp(DEVTOOLS_OVERLAY_URL)}")${pointing}${files} });</script>`;
120
+ const extra = `${fresh && engineTools.length + gameTools.length ? `, fresh: ${jsJson(fresh)}` : ''}${report ? `, report: ${jsJson(report)}` : ''}`;
121
+ return `<script type="module">import { startDevtools } from "${stamp(DEVTOOLS_URL)}"; startDevtools({ overlay: () => import("${stamp(DEVTOOLS_OVERLAY_URL)}")${pointing}${files}${extra} });</script>`;
119
122
  }
120
123
 
121
124
  // A page served through `joystack dev --share`'s tunnel: the hostname it was
@@ -163,7 +166,7 @@ export function stampedKinds(skeleton, pageUrl, stamp) {
163
166
  // modules the page names here have the one URL the modules that import them
164
167
  // name them by, and the browser keeps them while they do not change. Without
165
168
  // it they are named as they are.
166
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, engineTools = [], gameTools = [], point = false, skeleton = null, shareHost = null, replay, stamp = (url) => url } = {}) {
169
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, engineTools = [], gameTools = [], point = false, skeleton = null, shareHost = null, replay, stamp = (url) => url, fresh, report } = {}) {
167
170
  const parts = [];
168
171
  const tools = devtools ? gameTools : [];
169
172
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
@@ -184,7 +187,7 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
184
187
  parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
185
188
  parts.push(...platformTags(stamp(platformUrl), toolbox && stamp(toolboxUrl)));
186
189
  // Inject-if-present: only an engine that carries the runtime gets its tag.
187
- if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost, stamp }));
190
+ if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, stamp, fresh, report }));
188
191
  const inject = '\n ' + parts.join('\n ');
189
192
  const m = /<head[^>]*>/i.exec(html);
190
193
  if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
@@ -194,7 +197,8 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
194
197
  // Match <script src="X"> in HTML.
195
198
  const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
196
199
 
197
- // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
200
+ // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null; with a
201
+ // fresh token (fingerprint/), `?v=<closure-stamp>.<token>`. Stamped
198
202
  // extensions include .json: entity/config data is imported by games, and its
199
203
  // URL must rename on edit exactly like a module's.
200
204
  //
@@ -202,27 +206,27 @@ const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
202
206
  // disk-space fallback. Resolving a relative spec beside the importer on disk
203
207
  // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
204
208
  // which silently re-opens the staleness hole. No serving context → no stamp.
205
- function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
209
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context, fresh = null }) {
206
210
  if (!/\.(js|mjs|json)$/.test(relPath)) return null;
207
211
  if (baseUrl == null || !resolveUrl) return null;
208
212
  const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
209
213
  if (!urlTarget) return null;
210
214
  const stamp = context.stamp(urlTarget);
211
- return stamp === null ? null : `${relPath}?v=${stamp}`;
215
+ return stamp === null ? null : `${relPath}?v=${stampValue(stamp, fresh)}`;
212
216
  }
213
217
 
214
- export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
218
+ export function rewriteJsImports(text, { baseUrl, resolveUrl, fresh = null }) {
215
219
  const context = closureStamps(resolveUrl);
216
220
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
217
- const v = versionedPath(path, { baseUrl, resolveUrl, context });
221
+ const v = versionedPath(path, { baseUrl, resolveUrl, context, fresh });
218
222
  return `${prefix}${quote}${v ?? path}${quote}`;
219
223
  });
220
224
  }
221
225
 
222
- export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
226
+ export function rewriteHtmlScripts(html, { baseUrl, resolveUrl, fresh = null }) {
223
227
  const context = closureStamps(resolveUrl);
224
228
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
225
- const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
229
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context, fresh });
226
230
  return `${prefix}${v ?? path}${suffix}`;
227
231
  });
228
232
  }
package/lib/lint.mjs CHANGED
@@ -22,6 +22,7 @@ import { join, relative } from 'node:path';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { isFrameworkV2 } from './primitives.mjs';
24
24
  import { findProject, resolveEngine } from './project.mjs';
25
+ import { ensureEngine, ensureNamedEngine, namedEngineFailure } from './engine.mjs';
25
26
 
26
27
  const CONFIG_NAMES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
27
28
 
@@ -163,9 +164,23 @@ export async function lintCmd({
163
164
  loadEntityLint = loadEntityLintFromEngine,
164
165
  loadBuildGate = loadBuildGateFromEngine,
165
166
  loadHandbookCheck = loadHandbookCheckFromEngine,
167
+ ensure = ensureEngine,
166
168
  } = {}) {
167
169
  const project = findProject(cwd);
168
170
 
171
+ // Every check below loads its code out of the engine, so it has to be the
172
+ // engine the game names (engine.mjs, ensureNamedEngine). When that one cannot
173
+ // be installed the game is not checked at all: a lint run on another engine
174
+ // is the false green this exists to prevent.
175
+ try {
176
+ await ensureNamedEngine(project.dir, { ensure, log });
177
+ } catch (e) {
178
+ const error = namedEngineFailure(project.dir, e);
179
+ log(`❌ joystack lint: ${error}`);
180
+ log(' Your game was not checked: lint checks it on the engine it names, and no other.');
181
+ return { ok: false, error };
182
+ }
183
+
169
184
  // The entity graph check runs regardless of the eslint tooling below — a
170
185
  // missing dev tool must never switch off a structural guarantee. It skips
171
186
  // itself silently for games with no entity folders.
package/lib/mime.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // The content type the dev server sends for a file, by its extension.
2
+ export const MIME = {
3
+ '.html': 'text/html; charset=utf-8',
4
+ '.js': 'text/javascript; charset=utf-8',
5
+ '.mjs': 'text/javascript; charset=utf-8',
6
+ '.css': 'text/css; charset=utf-8',
7
+ '.json': 'application/json; charset=utf-8',
8
+ '.svg': 'image/svg+xml',
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.webp': 'image/webp',
14
+ '.ico': 'image/x-icon',
15
+ '.wasm': 'application/wasm',
16
+ '.mp3': 'audio/mpeg',
17
+ '.ogg': 'audio/ogg',
18
+ '.wav': 'audio/wav',
19
+ '.m4a': 'audio/mp4',
20
+ '.flac': 'audio/flac',
21
+ '.txt': 'text/plain; charset=utf-8',
22
+ '.md': 'text/markdown; charset=utf-8',
23
+ '.woff': 'font/woff',
24
+ '.woff2': 'font/woff2',
25
+ '.glb': 'model/gltf-binary',
26
+ '.gltf': 'model/gltf+json',
27
+ '.bin': 'application/octet-stream',
28
+ };
package/lib/relay/cli.mjs CHANGED
@@ -1,112 +1,61 @@
1
- // `joystack relay publish` and `joystack relay tail`: the agent's end of the
2
- // relay, from the game folder. Each running dev server for the folder wrote
3
- // its port and a bearer token to .joystack/relay/.connection-<port>
4
- // (connection.mjs), so only a process that can read the folder can speak as
5
- // the agent, and with several servers for one game every one of them hears.
1
+ // `joystack relay reply`: the agent's end of the relay, from the game
2
+ // folder. Each running dev server for the folder wrote its port and a bearer token
3
+ // to .joystack/relay/.connection-<port> (connection.mjs), so only a process
4
+ // that can read the folder can speak as the agent, and with several servers
5
+ // for one game every one of them hears.
6
6
  import { connections } from './connection.mjs';
7
7
 
8
- export const RELAY_HELP = `joystack relay publish [--dir <game>] <name> [<json>]
9
- Put an event on the relay of every running 'joystack dev' for this game, as
10
- the agent: <name> is <publisher>.<what> (agent.replied, agent.show), <json>
11
- its data. The events worth publishing, with their exact command lines, are
12
- in your game's AGENTS.md and the engine's docs.
13
- joystack relay tail [--dir <game>] [<pattern>…]
14
- Print every event on the relay as it happens, one JSON line each, until
15
- stopped. A pattern is a name whose segments may be *; none means every event.`;
8
+ export const RELAY_HELP = `joystack relay reply [--dir <game>] <note id> <text>
9
+ Answer a note the creator sent you from the running game: the text shows
10
+ under their note, even if the game reloaded since. The command line with
11
+ the note's id is in the message the note woke you with.`;
16
12
 
17
- const RELAY = '/__joystack/relay';
13
+ const RELAY_REQUEST = '/__joystack/relay';
18
14
 
19
- // Publish `data` under `name` on every server for `dir`. Resolves the number
20
- // of listeners reached, summed over the servers; throws when no server for
21
- // the folder is running.
22
- export async function relayPublish({ dir = process.cwd(), name, data = null, log = console.log }) {
23
- if (!name) throw new Error(`Usage: ${RELAY_HELP}`);
24
- let reached = 0;
25
- let delivered = 0;
26
- let agent = false;
15
+ // Ask every running dev server for `dir` the agent's `name` with `data`, as
16
+ // the agent. Resolves the answers, one per server reached, a refusal as an
17
+ // error answer; throws when no server for the folder is running.
18
+ async function askAsAgent(dir, name, data) {
19
+ const answers = [];
27
20
  for (const { port, token } of connections(dir)) {
28
21
  let res;
29
22
  try {
30
23
  // Always loopback: the credential names a port, never an address.
31
- res = await fetch(`http://127.0.0.1:${port}${RELAY}`, {
24
+ res = await fetch(`http://127.0.0.1:${port}${RELAY_REQUEST}`, {
32
25
  method: 'POST',
33
26
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
34
27
  body: JSON.stringify({ name, data }),
35
28
  signal: AbortSignal.timeout(5000),
36
29
  });
37
30
  } catch { continue; } // that server has stopped
38
- reached += 1;
39
- if (!res.ok) {
40
- // Whatever answers on that port wrote the body; it is reported by
41
- // status only, never echoed.
42
- throw new Error(`The dev server on port ${port} refused the event (HTTP ${res.status}).`);
43
- }
44
- const out = await res.json().catch(() => ({}));
45
- delivered += Number(out.delivered) || 0;
46
- agent = agent || out.agent === true;
31
+ // Whatever answers on that port wrote the body; a refusal is reported by
32
+ // status only, never echoed. Another server for the same folder (one
33
+ // from an older CLI, say) may still take it.
34
+ if (!res.ok) { answers.push({ ok: false, reason: 'error', message: `The dev server on port ${port} refused it (HTTP ${res.status}).` }); continue; }
35
+ answers.push(await res.json().catch(() => ({ ok: false, reason: 'error', message: 'not an answer' })));
47
36
  }
48
- if (!reached) throw new Error(`No running 'joystack dev' for ${dir}.`);
49
- log(`Published ${name} to ${delivered} listener${delivered === 1 ? '' : 's'}.`);
50
- return { delivered, agent };
37
+ if (!answers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
38
+ return answers;
51
39
  }
52
40
 
53
- // Read one server's stream, calling `onEvent` for each event frame, until
54
- // the stream ends or `signal` aborts.
55
- async function tailOne(port, token, patterns, onEvent, signal) {
56
- const listen = encodeURIComponent(JSON.stringify(patterns.map((p) => [p, false])));
57
- const res = await fetch(`http://127.0.0.1:${port}${RELAY}/stream?listen=${listen}`, { headers: { Authorization: `Bearer ${token}` }, signal });
58
- if (!res.ok) throw new Error(`The dev server on port ${port} refused the stream (HTTP ${res.status}).`);
59
- let buffer = '';
60
- for await (const chunk of res.body) {
61
- buffer += Buffer.from(chunk).toString('utf8');
62
- let at;
63
- while ((at = buffer.indexOf('\n\n')) !== -1) {
64
- const block = buffer.slice(0, at);
65
- buffer = buffer.slice(at + 2);
66
- const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
67
- if (!data) continue;
68
- let frame;
69
- try { frame = JSON.parse(data); } catch { continue; }
70
- if (frame.type === 'event' && frame.event) onEvent(frame.event, port);
71
- }
72
- }
73
- }
74
-
75
- // Tail every server for `dir`. Resolves when every stream has ended (the
76
- // servers stopped, or `signal` aborted); throws when none is running.
77
- export async function relayTail({ dir = process.cwd(), patterns = [], onEvent = (e) => console.log(JSON.stringify(e)), signal = undefined, log = console.error } = {}) {
78
- const servers = connections(dir);
79
- if (!servers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
80
- const wanted = patterns.length ? patterns : ['*'];
81
- log(`Tailing ${wanted.join(', ')} on ${servers.length} dev server${servers.length === 1 ? '' : 's'} (port${servers.length === 1 ? '' : 's'} ${servers.map((s) => s.port).join(', ')})…`);
82
- const results = await Promise.allSettled(servers.map(({ port, token }) => tailOne(port, token, wanted, onEvent, signal)));
83
- const failed = results.filter((r) => r.status === 'rejected' && r.reason?.name !== 'AbortError');
84
- if (failed.length === results.length) throw failed[0].reason;
41
+ // Answer the note `id` with `text`, on every dev server running for `dir`:
42
+ // each keeps the same file and announces it to the pages it serves.
43
+ export async function relayReply({ dir = process.cwd(), id, text, log = console.log }) {
44
+ if (!id || !text?.trim()) throw new Error(`Usage: ${RELAY_HELP}`);
45
+ const answers = await askAsAgent(dir, 'agent.reply', { to: id, text });
46
+ const kept = answers.filter((a) => a?.ok);
47
+ if (!kept.length) throw new Error(answers[0]?.message || 'The reply was refused.');
48
+ const shown = kept.some((a) => a.value?.shown);
49
+ log(shown ? `Replied to note ${id}; it shows under the note.` : `Replied to note ${id}; it shows under the note when the creator's page is open again.`);
50
+ return { shown };
85
51
  }
86
52
 
87
- // The command line: `relay publish [--dir d] <name> [<json>]` or `relay tail
88
- // [--dir d] [<pattern>…]`.
53
+ // The command line: `relay reply [--dir d] <note id> <text>`.
89
54
  export async function relayCommand(rest, { dir, log = console.log } = {}) {
90
55
  const [verb, ...args] = rest;
91
- if (verb === 'publish') {
92
- const [name, raw] = args;
93
- let data = null;
94
- if (raw !== undefined) {
95
- try { data = JSON.parse(raw); } catch { throw new Error(`The data must be JSON, for example '{"to":"ab12cd34","text":"Done."}'. Usage: ${RELAY_HELP}`); }
96
- }
97
- return relayPublish({ dir, name, data, log });
98
- }
99
- if (verb === 'tail') {
100
- const controller = new AbortController();
101
- const stop = () => controller.abort();
102
- process.once('SIGINT', stop);
103
- process.once('SIGTERM', stop);
104
- try {
105
- return await relayTail({ dir, patterns: args, signal: controller.signal });
106
- } finally {
107
- process.off('SIGINT', stop);
108
- process.off('SIGTERM', stop);
109
- }
56
+ if (verb === 'reply') {
57
+ const [id, ...words] = args;
58
+ return relayReply({ dir, id, text: words.join(' '), log });
110
59
  }
111
60
  throw new Error(`Usage: ${RELAY_HELP}`);
112
61
  }