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