@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 +38 -0
- package/bin/joystack.mjs +1 -2
- package/lib/assets-export.mjs +17 -5
- package/lib/assets-page.mjs +17 -12
- package/lib/dev.mjs +7 -2
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +6 -0
- package/lib/fingerprint/fingerprint.mjs +41 -1
- package/lib/inject.mjs +20 -16
- package/lib/lint.mjs +15 -0
- package/lib/mime.mjs +28 -0
- package/lib/relay/cli.mjs +37 -88
- package/lib/relay/connection.mjs +2 -3
- package/lib/relay/conversation.mjs +15 -15
- package/lib/relay/engine.mjs +15 -0
- package/lib/relay/http.mjs +57 -0
- package/lib/relay/relay.mjs +267 -257
- package/lib/reload-client.mjs +58 -0
- package/lib/room-server.mjs +10 -0
- package/lib/shot/README.md +1 -1
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +91 -99
- package/lib/test-cmd.mjs +12 -0
- package/package.json +3 -2
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, ``; 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
|
|
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
|
package/lib/assets-export.mjs
CHANGED
|
@@ -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.
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `}"` : ''})`;
|
|
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
|
package/lib/assets-page.mjs
CHANGED
|
@@ -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
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
|
189
|
+
let text = '';
|
|
186
190
|
res.setEncoding('utf8');
|
|
187
|
-
res.on('data', (d) => { if (
|
|
191
|
+
res.on('data', (d) => { if (text.length < 65536) text += d; });
|
|
188
192
|
res.on('end', () => {
|
|
189
|
-
try { resolve(res.statusCode === 200 ? JSON.parse(
|
|
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
|
|
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 `
|
|
80
|
-
* here; `joystack test`
|
|
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.
|
|
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.
|
|
111
|
-
//
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
13
|
+
const RELAY_REQUEST = '/__joystack/relay';
|
|
18
14
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// the folder is running.
|
|
22
|
-
|
|
23
|
-
|
|
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}${
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (!
|
|
49
|
-
|
|
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
|
-
//
|
|
54
|
-
// the
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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 === '
|
|
92
|
-
const [
|
|
93
|
-
|
|
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
|
}
|