@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/lib/room-server.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/shot/README.md
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/lib/static-server.mjs
CHANGED
|
@@ -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
|
|
224
|
-
//
|
|
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
|
|
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
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
|
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
|
-
|
|
481
|
-
|
|
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
|
|
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.
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|