@joystack/cli 0.1.54 → 0.1.55
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 +41 -0
- package/bin/joystack.mjs +38 -0
- package/lib/assets-export.mjs +198 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +15 -5
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +15 -10
- package/lib/fingerprint/README.md +16 -0
- package/lib/fingerprint/fingerprint.mjs +195 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +64 -148
- package/lib/maker.mjs +16 -2
- 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/static-server.mjs +104 -22
- package/lib/test-cmd.mjs +29 -12
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// A file's address from its content: the stamp, the cache headers and the
|
|
2
|
+
// files a game's start needs. See README.md.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
|
|
7
|
+
export const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
|
|
8
|
+
|
|
9
|
+
// A spec that can never name a local file the server would stamp: absolute
|
|
10
|
+
// URLs, data:/blob:, and anything already carrying a query or fragment.
|
|
11
|
+
export function isExternalSpec(spec) {
|
|
12
|
+
return (
|
|
13
|
+
/^(https?:)?\/\//.test(spec) ||
|
|
14
|
+
spec.startsWith('data:') ||
|
|
15
|
+
spec.startsWith('blob:') ||
|
|
16
|
+
spec.includes('?') ||
|
|
17
|
+
spec.includes('#')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Resolve an import specifier the way the BROWSER does — against the importing
|
|
22
|
+
// module's URL — returning an absolute URL path, or null for bare/external
|
|
23
|
+
// specifiers. Stamping lives in URL space because that is where module identity
|
|
24
|
+
// lives: an engine file's relative sibling import can be shadowed by the
|
|
25
|
+
// game's overrides mount, so the file the URL serves is not the file that sits
|
|
26
|
+
// next to the importer on disk.
|
|
27
|
+
export function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
|
|
28
|
+
if (isExternalSpec(spec)) return null;
|
|
29
|
+
if (spec.startsWith('/')) return spec;
|
|
30
|
+
if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
|
|
31
|
+
try {
|
|
32
|
+
return new URL(spec, 'http://x' + baseUrl).pathname;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hashOf = (bytes) => createHash('sha1').update(bytes).digest('hex');
|
|
39
|
+
|
|
40
|
+
// What a file is, for its stamp: a hash of its bytes and, for a module, the
|
|
41
|
+
// RAW specifiers it imports. Kept by path and re-read when its time, change
|
|
42
|
+
// time or size moves. Specifiers resolve per walk, because the same file's
|
|
43
|
+
// specifiers resolve differently through different mount tables (two
|
|
44
|
+
// servers in one process must not contaminate each other). Only
|
|
45
|
+
// string-literal static and dynamic imports are seen: import(expr) and
|
|
46
|
+
// new URL(...) are computed at run time and stay unstamped.
|
|
47
|
+
const fileCache = new Map(); // abs fs path -> { mtimeMs, ctimeMs, size, hash, specs }
|
|
48
|
+
const RACY_MS = 2000;
|
|
49
|
+
|
|
50
|
+
function fileInfo(path) {
|
|
51
|
+
let st;
|
|
52
|
+
try {
|
|
53
|
+
st = statSync(path);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!st.isFile()) return null;
|
|
58
|
+
const hit = fileCache.get(path);
|
|
59
|
+
// A file written in the last moments is read again: a second write of the
|
|
60
|
+
// same size inside the clock's tick (coarse on some disks, Windows among
|
|
61
|
+
// them) leaves its times and size as they were, and its old hash would
|
|
62
|
+
// stand.
|
|
63
|
+
const settled = Date.now() - Math.max(st.mtimeMs, st.ctimeMs) > RACY_MS;
|
|
64
|
+
if (hit && settled && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
|
|
65
|
+
let bytes;
|
|
66
|
+
try {
|
|
67
|
+
bytes = readFileSync(path);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const specs = [];
|
|
72
|
+
if (/\.(js|mjs)$/.test(path)) {
|
|
73
|
+
for (const m of bytes.toString('utf8').matchAll(JS_IMPORT_RE)) {
|
|
74
|
+
if (!isExternalSpec(m[3])) specs.push(m[3]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, hash: hashOf(bytes), specs };
|
|
78
|
+
fileCache.set(path, info);
|
|
79
|
+
return info;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The stamps of one request: a URL's stamp is a hash of every file in its
|
|
83
|
+
// transitive import closure, each by its URL and its content. Module identity
|
|
84
|
+
// is the full URL, and a browser reuses a body it holds for an unchanged URL,
|
|
85
|
+
// so an edit must rename every URL on the path from the entry down: a stamp of
|
|
86
|
+
// the file alone would leave its importers' URLs, and the whole pre-edit
|
|
87
|
+
// graph, in the browser's hands. Made of content and not of file times, a
|
|
88
|
+
// stamp survives a copy of the game (a dev server freezes one at start) and a
|
|
89
|
+
// restart, so a browser keeps what has not changed.
|
|
90
|
+
//
|
|
91
|
+
// Resolved nodes and stamps are shared across the closures of one request and
|
|
92
|
+
// never kept past it: the next request sees edits, deletions and newly made
|
|
93
|
+
// overrides at once, without waiting for the reload watcher.
|
|
94
|
+
export function closureStamps(resolveUrl) {
|
|
95
|
+
const nodes = new Map();
|
|
96
|
+
const stamps = new Map();
|
|
97
|
+
const node = (url) => {
|
|
98
|
+
if (!nodes.has(url)) {
|
|
99
|
+
const path = resolveUrl(url);
|
|
100
|
+
nodes.set(url, path ? fileInfo(path) : null);
|
|
101
|
+
}
|
|
102
|
+
return nodes.get(url);
|
|
103
|
+
};
|
|
104
|
+
// The URLs of the files a URL's closure holds, itself first; a file that
|
|
105
|
+
// cannot be found is left out.
|
|
106
|
+
const closure = (urlPath) => {
|
|
107
|
+
const members = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const stack = [urlPath];
|
|
110
|
+
while (stack.length) {
|
|
111
|
+
const url = stack.pop();
|
|
112
|
+
if (seen.has(url)) continue;
|
|
113
|
+
seen.add(url);
|
|
114
|
+
const info = node(url);
|
|
115
|
+
if (!info) continue;
|
|
116
|
+
members.push(url);
|
|
117
|
+
for (const spec of info.specs) {
|
|
118
|
+
const child = resolveSpecToUrl(spec, url);
|
|
119
|
+
if (child) stack.push(child);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return members;
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
node,
|
|
126
|
+
closure,
|
|
127
|
+
stamp(urlPath) {
|
|
128
|
+
if (stamps.has(urlPath)) return stamps.get(urlPath);
|
|
129
|
+
const members = closure(urlPath).map((url) => `${url}\0${node(url).hash}`);
|
|
130
|
+
const stamp = members.length ? hashOf(members.sort().join('\n')).slice(0, 12) : null;
|
|
131
|
+
stamps.set(urlPath, stamp);
|
|
132
|
+
return stamp;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A stamped address in a page: in quotes, ending in `?v=<stamp>`.
|
|
138
|
+
const STAMPED_RE = /["']([^"'\s<>]+)\?v=[0-9a-f]{12}["']/g;
|
|
139
|
+
|
|
140
|
+
// The files a game's start downloads, each under the stamped URL it is asked
|
|
141
|
+
// for by: every module of the closure of each stamped address the served page
|
|
142
|
+
// names (its scripts, its import map, the injected tags, the skeleton's
|
|
143
|
+
// entity modules), literal dynamic imports included. `html` is the page as the
|
|
144
|
+
// dev server serves it, `pageUrl` its URL. `plain` names modules the page asks
|
|
145
|
+
// for under their plain URLs (the dev tools' tool files, whose names the
|
|
146
|
+
// runtime reads from the file's): each is listed as it is, and what it
|
|
147
|
+
// imports, stamped.
|
|
148
|
+
export function startFiles({ html, pageUrl, resolveUrl, plain = [] }) {
|
|
149
|
+
const stamps = closureStamps(resolveUrl);
|
|
150
|
+
const files = new Set();
|
|
151
|
+
const roots = [];
|
|
152
|
+
for (const m of String(html).matchAll(STAMPED_RE)) {
|
|
153
|
+
const root = resolveSpecToUrl(m[1], pageUrl, { allowBareRelative: true });
|
|
154
|
+
if (root) roots.push(root);
|
|
155
|
+
}
|
|
156
|
+
for (const url of plain) {
|
|
157
|
+
if (!stamps.node(url)) continue;
|
|
158
|
+
files.add(url);
|
|
159
|
+
roots.push(url);
|
|
160
|
+
}
|
|
161
|
+
for (const root of roots) {
|
|
162
|
+
for (const url of stamps.closure(root)) {
|
|
163
|
+
if (plain.includes(url)) continue;
|
|
164
|
+
// Only these are imported by stamped URLs (inject.mjs, versionedPath).
|
|
165
|
+
if (!/\.(js|mjs|json)$/.test(url)) continue;
|
|
166
|
+
const stamp = stamps.stamp(url);
|
|
167
|
+
if (stamp) files.add(`${url}?v=${stamp}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...files];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The headers for a file the dev server sends (a module, a model, a sound,
|
|
174
|
+
// data), never for a game's page, which is always fetched fresh. `requested`
|
|
175
|
+
// is the stamp the URL carries (`?v=`), `current` the file's stamp now. Only
|
|
176
|
+
// a URL whose stamp is current is kept for good: an older page asking for an
|
|
177
|
+
// address whose file has since changed gets the new bytes, which must not be
|
|
178
|
+
// kept under the old address. Everything else carries a tag of its bytes and
|
|
179
|
+
// is checked with the server each time, a round trip and no download when it
|
|
180
|
+
// has not changed. `private`: a maker live link serves only its owner.
|
|
181
|
+
export function cacheHeaders({ requested, current, body }) {
|
|
182
|
+
if (requested && current && requested === current) {
|
|
183
|
+
return { 'Cache-Control': 'private, max-age=31536000, immutable' };
|
|
184
|
+
}
|
|
185
|
+
return { 'Cache-Control': 'no-cache', ETag: `"${hashOf(body)}"` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Whether a request already holds these bytes (its If-None-Match names the
|
|
189
|
+
// tag, weak or strong, alone or in a list).
|
|
190
|
+
export function notModified(req, etag) {
|
|
191
|
+
const header = req.headers?.['if-none-match'];
|
|
192
|
+
if (!header || !etag) return false;
|
|
193
|
+
return header.split(',').some((t) => t.trim().replace(/^W\//, '') === etag);
|
|
194
|
+
}
|
|
195
|
+
export { declaredAssets, WARM_PAGE, WARM_PATH } from './warm.mjs';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The warm page the dev server serves at /__joystack/warm/<slug>/, which
|
|
2
|
+
// downloads a game's start ahead of it, and the declared assets its list
|
|
3
|
+
// carries beside the start's modules (startFiles). See README.md.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const WARM_PATH = '/__joystack/warm';
|
|
8
|
+
|
|
9
|
+
// The warm page (static-server.mjs, serveWarm): it downloads each file of
|
|
10
|
+
// its list, a few at a time and at low priority, into the cache the game's
|
|
11
|
+
// own page reads, and again whenever the server moves to a new version. A pass started while one
|
|
12
|
+
// runs waits for it. `data-warmed` on the document counts the files of the
|
|
13
|
+
// last finished pass.
|
|
14
|
+
export const WARM_PAGE = `<!doctype html><meta charset="utf-8"><title>warm</title><script>
|
|
15
|
+
(() => {
|
|
16
|
+
let running = false;
|
|
17
|
+
let again = false;
|
|
18
|
+
async function warm() {
|
|
19
|
+
if (running) { again = true; return; }
|
|
20
|
+
running = true;
|
|
21
|
+
try {
|
|
22
|
+
const { files = [] } = await (await fetch('files.json', { cache: 'no-store' })).json();
|
|
23
|
+
let i = 0;
|
|
24
|
+
const next = async () => {
|
|
25
|
+
while (i < files.length) {
|
|
26
|
+
const url = files[i++];
|
|
27
|
+
try { await (await fetch(url, { priority: 'low' })).arrayBuffer(); } catch {}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
await Promise.all([next(), next(), next(), next()]);
|
|
31
|
+
document.documentElement.dataset.warmed = String(files.length);
|
|
32
|
+
} catch {}
|
|
33
|
+
running = false;
|
|
34
|
+
if (again) { again = false; warm(); }
|
|
35
|
+
}
|
|
36
|
+
warm();
|
|
37
|
+
const es = new EventSource('/__reload?page=warm');
|
|
38
|
+
es.onmessage = (e) => { if (e.data === 'reload') warm(); };
|
|
39
|
+
})();
|
|
40
|
+
</script>`;
|
|
41
|
+
|
|
42
|
+
// The URLs of the files the game's assets.js declares that the server has,
|
|
43
|
+
// read by the engine's own reader of the declaration; none from an engine
|
|
44
|
+
// without one.
|
|
45
|
+
export async function declaredAssets({ slug, resolveUrl }) {
|
|
46
|
+
const decl = resolveUrl(`/games/${slug}/assets.js`);
|
|
47
|
+
const reader = resolveUrl('/shared/ui/assets/declared.js');
|
|
48
|
+
if (!decl || !reader) return [];
|
|
49
|
+
try {
|
|
50
|
+
const { readDeclaration } = await import(pathToFileURL(reader).href);
|
|
51
|
+
const base = `http://x/games/${slug}/`;
|
|
52
|
+
return [...readDeclaration(readFileSync(decl, 'utf8')).sources.values()]
|
|
53
|
+
.map((src) => { try { return new URL(src, base); } catch { return null; } })
|
|
54
|
+
.filter((u) => u && u.origin === 'http://x' && resolveUrl(u.pathname))
|
|
55
|
+
// Under the URL the game asks for, its query included.
|
|
56
|
+
.map((u) => u.pathname + u.search);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/inject.mjs
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
// its old name (LEGACY_PAGE_GLOBALS) is set under both names here too. New
|
|
8
8
|
// platform features are added to shared/platform/platform.js FEATURES —
|
|
9
9
|
// never here.
|
|
10
|
-
// * ?v=<stamp> rewriting on <script src> and JS imports
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// * ?v=<stamp> rewriting on <script src> and JS imports: each URL carries the
|
|
11
|
+
// stamp of its import closure's content (fingerprint/), so an edit renames
|
|
12
|
+
// it and an unchanged file keeps its address, and with it the browser's
|
|
13
|
+
// copy.
|
|
13
14
|
import { dirname } from 'node:path';
|
|
15
|
+
import { closureStamps, JS_IMPORT_RE, resolveSpecToUrl } from './fingerprint/fingerprint.mjs';
|
|
14
16
|
import { LEGACY_PAGE_GLOBALS, withLegacyDoors } from './legacy-names.mjs';
|
|
15
17
|
|
|
16
18
|
// The identity every `joystack dev` page is signed in as.
|
|
@@ -96,9 +98,11 @@ function setGlobal(scope, name, value) {
|
|
|
96
98
|
// The page's import map: the framework's doors for a v2 game, and
|
|
97
99
|
// `joystack/tools` when the game has tool files, which import it. A v1 page
|
|
98
100
|
// with tools gets a map of that door alone; a page with neither, none.
|
|
99
|
-
function importMap(v2, tools) {
|
|
101
|
+
function importMap(v2, tools, stamp) {
|
|
100
102
|
if (!v2 && !tools) return '';
|
|
101
103
|
const imports = { ...(v2 ? withLegacyDoors(V2_IMPORTS) : {}), ...(tools ? DEVTOOLS_DOOR : {}) };
|
|
104
|
+
// A prefix (`three/addons/`) names a folder, which has no stamp.
|
|
105
|
+
for (const [key, url] of Object.entries(imports)) if (!url.endsWith('/')) imports[key] = stamp(url);
|
|
102
106
|
return `<script type="importmap">${jsJson({ imports })}</script>`;
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -106,15 +110,60 @@ function importMap(v2, tools) {
|
|
|
106
110
|
// the tool files. `point`: the engine has Point. `shared`: the page is
|
|
107
111
|
// reached through a share link (the maker's live link), where Point is the
|
|
108
112
|
// maker's only: opened alone, nobody hears a pick.
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
// The tool files keep their plain URLs: the runtime reads a tool's name from
|
|
114
|
+
// its file's (shared/ui/devtools/game-tools.js), and a stamp would end it.
|
|
115
|
+
function devtoolsTag(engineTools, gameTools, { point = false, shared = false, stamp = (url) => url } = {}) {
|
|
116
|
+
const pointing = point ? `, point: () => import("${stamp(POINT_URL)}")${shared ? ', pointAlone: false' : ''}` : '';
|
|
111
117
|
const files = `${engineTools.length ? `, engineTools: ${jsJson(engineTools)}` : ''}${gameTools.length ? `, gameTools: ${jsJson(gameTools)}` : ''}`;
|
|
112
|
-
return `<script type="module">import { startDevtools } from "${DEVTOOLS_URL}"; startDevtools({ overlay: () => import("${DEVTOOLS_OVERLAY_URL}")${pointing}${files} });</script>`;
|
|
118
|
+
return `<script type="module">import { startDevtools } from "${stamp(DEVTOOLS_URL)}"; startDevtools({ overlay: () => import("${stamp(DEVTOOLS_OVERLAY_URL)}")${pointing}${files} });</script>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A page served through `joystack dev --share`'s tunnel: the hostname it was
|
|
122
|
+
// shared on, which the engine's isSharedDevPage compares with the page's own
|
|
123
|
+
// before treating a public name as a dev page. Ahead of every module script,
|
|
124
|
+
// like the skeleton, because the room transport reads it as the game boots.
|
|
125
|
+
// Then the game's slug.
|
|
126
|
+
function pageNames(shareHost, slug) {
|
|
127
|
+
const tags = [];
|
|
128
|
+
if (shareHost) tags.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
|
|
129
|
+
if (slug) tags.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
|
|
130
|
+
return tags;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The platform layer's tag, then the old toolbox's when `toolboxUrl` is
|
|
134
|
+
// given. Dev-only, opt-in per request: the server asks for the toolbox only
|
|
135
|
+
// when the resolved engine bundle carries the module (inject-if-present), so
|
|
136
|
+
// an older pinned engine under a newer CLI never gets a 404ing tag. Behind
|
|
137
|
+
// the platform tag: the boot gate's error handlers come up first.
|
|
138
|
+
function platformTags(platformUrl, toolboxUrl) {
|
|
139
|
+
const tags = [`<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`];
|
|
140
|
+
if (toolboxUrl) tags.push(`<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`);
|
|
141
|
+
return tags;
|
|
113
142
|
}
|
|
114
143
|
|
|
115
144
|
const devFlag = (replay) => `<script>window.JOYSTACK_DEV = ${jsJson({ dev: true, tools: true, replay })};</script>`;
|
|
116
145
|
|
|
117
|
-
|
|
146
|
+
// The skeleton with each entity module named by its stamped URL. startGame()
|
|
147
|
+
// imports the kinds by these names, relative to the page, while the game's
|
|
148
|
+
// own modules import the same files by their stamped URLs: under two URLs a
|
|
149
|
+
// module would run twice, and the kinds it defines would not be the ones the
|
|
150
|
+
// world spawns. A module the server cannot find keeps its name. `pageUrl` is
|
|
151
|
+
// the page's own URL, which the skeleton's module names are relative to.
|
|
152
|
+
export function stampedKinds(skeleton, pageUrl, stamp) {
|
|
153
|
+
if (!skeleton?.kinds || pageUrl == null) return skeleton;
|
|
154
|
+
const kinds = skeleton.kinds.map((k) => {
|
|
155
|
+
const url = typeof k.module === 'string' ? resolveSpecToUrl(k.module, pageUrl, { allowBareRelative: true }) : null;
|
|
156
|
+
const stamped = url ? stamp(url) : null;
|
|
157
|
+
return stamped && stamped !== url ? { ...k, module: k.module + stamped.slice(url.length) } : k;
|
|
158
|
+
});
|
|
159
|
+
return { ...skeleton, kinds };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// `stamp(url)` gives an engine module's URL its stamp (fingerprint/), so the
|
|
163
|
+
// modules the page names here have the one URL the modules that import them
|
|
164
|
+
// name them by, and the browser keeps them while they do not change. Without
|
|
165
|
+
// 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 } = {}) {
|
|
118
167
|
const parts = [];
|
|
119
168
|
const tools = devtools ? gameTools : [];
|
|
120
169
|
// A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
|
|
@@ -122,15 +171,10 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
|
|
|
122
171
|
// any module script (the platform tag AND game.js): a browser ignores an import
|
|
123
172
|
// map once a module has begun executing, and startGame() reads the skeleton at
|
|
124
173
|
// module-eval time. So they go FIRST.
|
|
125
|
-
const map = importMap(!!skeleton, tools.length > 0);
|
|
174
|
+
const map = importMap(!!skeleton, tools.length > 0, stamp);
|
|
126
175
|
if (map) parts.push(map);
|
|
127
176
|
if (skeleton) parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
|
|
128
|
-
|
|
129
|
-
// shared on, which the engine's isSharedDevPage compares with the page's own
|
|
130
|
-
// before treating a public name as a dev page. Ahead of every module script,
|
|
131
|
-
// like the skeleton, because the room transport reads it as the game boots.
|
|
132
|
-
if (shareHost) parts.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
|
|
133
|
-
if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
|
|
177
|
+
parts.push(...pageNames(shareHost, slug));
|
|
134
178
|
// Every page this server serves is a dev page carrying the dev tools: the
|
|
135
179
|
// engine's one page-side dev answer (`devPage`, shared/ui/room/lib/dev-host.js)
|
|
136
180
|
// reads it, ahead of every module. `replay`: the page is the creator's own,
|
|
@@ -138,145 +182,18 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
|
|
|
138
182
|
// may not.
|
|
139
183
|
parts.push(devFlag(replay !== false));
|
|
140
184
|
parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
|
|
141
|
-
parts.push(
|
|
142
|
-
`<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
|
|
143
|
-
);
|
|
144
|
-
// Dev-only, opt-in per request: the server sets `toolbox` only when the
|
|
145
|
-
// resolved engine bundle carries the module (inject-if-present), so an older
|
|
146
|
-
// pinned engine under a newer CLI never gets a 404ing tag. Behind the
|
|
147
|
-
// platform tag — the boot gate's error handlers come up first.
|
|
148
|
-
if (toolbox) {
|
|
149
|
-
parts.push(
|
|
150
|
-
`<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
|
|
151
|
-
);
|
|
152
|
-
}
|
|
185
|
+
parts.push(...platformTags(stamp(platformUrl), toolbox && stamp(toolboxUrl)));
|
|
153
186
|
// Inject-if-present: only an engine that carries the runtime gets its tag.
|
|
154
|
-
if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost }));
|
|
187
|
+
if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost, stamp }));
|
|
155
188
|
const inject = '\n ' + parts.join('\n ');
|
|
156
189
|
const m = /<head[^>]*>/i.exec(html);
|
|
157
190
|
if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
|
|
158
191
|
return parts.join('\n') + '\n' + html;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
|
-
// Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
|
|
162
|
-
const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
|
|
163
194
|
// Match <script src="X"> in HTML.
|
|
164
195
|
const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
|
|
165
196
|
|
|
166
|
-
// A spec that can never name a local file the server would stamp: absolute
|
|
167
|
-
// URLs, data:/blob:, and anything already carrying a query or fragment.
|
|
168
|
-
function isExternalSpec(spec) {
|
|
169
|
-
return (
|
|
170
|
-
/^(https?:)?\/\//.test(spec) ||
|
|
171
|
-
spec.startsWith('data:') ||
|
|
172
|
-
spec.startsWith('blob:') ||
|
|
173
|
-
spec.includes('?') ||
|
|
174
|
-
spec.includes('#')
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Resolve an import specifier the way the BROWSER does — against the importing
|
|
179
|
-
// module's URL — returning an absolute URL path, or null for bare/external
|
|
180
|
-
// specs. Stamping must live in URL space because that is where module identity
|
|
181
|
-
// lives: an engine file's relative sibling import can be shadowed by the
|
|
182
|
-
// game's overrides mount, so the file the URL serves is not the file that sits
|
|
183
|
-
// next to the importer on disk.
|
|
184
|
-
function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
|
|
185
|
-
if (isExternalSpec(spec)) return null;
|
|
186
|
-
if (spec.startsWith('/')) return spec;
|
|
187
|
-
if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
|
|
188
|
-
try {
|
|
189
|
-
return new URL(spec, 'http://x' + baseUrl).pathname;
|
|
190
|
-
} catch {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// The version stamp is the max mtime over the module's TRANSITIVE static
|
|
196
|
-
// import closure, not the file's own mtime. Module identity is the full URL,
|
|
197
|
-
// and a browser may reuse a cached body for an unchanged URL across reloads
|
|
198
|
-
// (Chrome's memory cache does not honor no-store for same-URL subresources).
|
|
199
|
-
// A per-target stamp renames only the edited file's URL — its importers' URLs
|
|
200
|
-
// stay put, so a reloading page reassembles the whole pre-edit graph from
|
|
201
|
-
// cache with zero requests and no error anywhere. Stamping the closure renames
|
|
202
|
-
// every URL on the path from the entry (which a navigation always re-fetches),
|
|
203
|
-
// so no URL-keyed cache can serve stale bytes.
|
|
204
|
-
//
|
|
205
|
-
// The parse cache stores RAW specifiers keyed by file path + mtime; resolution
|
|
206
|
-
// happens per walk, because the same file's specs resolve differently through
|
|
207
|
-
// different mount tables (two servers in one process must not contaminate each
|
|
208
|
-
// other). Boundary: only string-literal static/dynamic imports are seen —
|
|
209
|
-
// import(expr) and new URL(...) are runtime-computed and stay unstamped.
|
|
210
|
-
const parseCache = new Map(); // abs fs path -> { mtimeMs, specs: string[] }
|
|
211
|
-
|
|
212
|
-
function fileInfo(path) {
|
|
213
|
-
let st;
|
|
214
|
-
try {
|
|
215
|
-
st = statSync(path);
|
|
216
|
-
} catch {
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
const hit = parseCache.get(path);
|
|
220
|
-
if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
|
|
221
|
-
const specs = [];
|
|
222
|
-
if (/\.(js|mjs)$/.test(path)) {
|
|
223
|
-
let text = null;
|
|
224
|
-
try {
|
|
225
|
-
text = readFileSync(path, 'utf8');
|
|
226
|
-
} catch {
|
|
227
|
-
/* unreadable: stamp from its own mtime alone */
|
|
228
|
-
}
|
|
229
|
-
if (text !== null) {
|
|
230
|
-
for (const m of text.matchAll(JS_IMPORT_RE)) {
|
|
231
|
-
if (!isExternalSpec(m[3])) specs.push(m[3]);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
|
|
236
|
-
parseCache.set(path, info);
|
|
237
|
-
return info;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// A rewrite shares resolved nodes across all import closures. Each URL is
|
|
241
|
-
// resolved/stat'ed once even when several roots reach it. The cache belongs to
|
|
242
|
-
// this synchronous rewrite only: the next request observes edits, deletions and
|
|
243
|
-
// newly-created overrides immediately, without waiting for the reload watcher.
|
|
244
|
-
function rewriteContext(resolveUrl) {
|
|
245
|
-
const nodes = new Map();
|
|
246
|
-
return {
|
|
247
|
-
stamps: new Map(),
|
|
248
|
-
node(url) {
|
|
249
|
-
if (!nodes.has(url)) {
|
|
250
|
-
const path = resolveUrl(url);
|
|
251
|
-
nodes.set(url, path ? fileInfo(path) : null);
|
|
252
|
-
}
|
|
253
|
-
return nodes.get(url);
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function closureStampUrl(urlPath, context) {
|
|
259
|
-
if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
|
|
260
|
-
let max = 0;
|
|
261
|
-
const seen = new Set();
|
|
262
|
-
const stack = [urlPath];
|
|
263
|
-
while (stack.length) {
|
|
264
|
-
const url = stack.pop();
|
|
265
|
-
if (seen.has(url)) continue;
|
|
266
|
-
seen.add(url);
|
|
267
|
-
const info = context.node(url);
|
|
268
|
-
if (!info) continue;
|
|
269
|
-
max = Math.max(max, info.mtimeMs);
|
|
270
|
-
for (const spec of info.specs) {
|
|
271
|
-
const child = resolveSpecToUrl(spec, url);
|
|
272
|
-
if (child) stack.push(child);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
const stamp = max ? Math.floor(max) : null;
|
|
276
|
-
context.stamps.set(urlPath, stamp);
|
|
277
|
-
return stamp;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
197
|
// Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
|
|
281
198
|
// extensions include .json: entity/config data is imported by games, and its
|
|
282
199
|
// URL must rename on edit exactly like a module's.
|
|
@@ -290,13 +207,12 @@ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false
|
|
|
290
207
|
if (baseUrl == null || !resolveUrl) return null;
|
|
291
208
|
const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
|
|
292
209
|
if (!urlTarget) return null;
|
|
293
|
-
|
|
294
|
-
const stamp = closureStampUrl(urlTarget, context);
|
|
210
|
+
const stamp = context.stamp(urlTarget);
|
|
295
211
|
return stamp === null ? null : `${relPath}?v=${stamp}`;
|
|
296
212
|
}
|
|
297
213
|
|
|
298
214
|
export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
|
|
299
|
-
const context =
|
|
215
|
+
const context = closureStamps(resolveUrl);
|
|
300
216
|
return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
|
|
301
217
|
const v = versionedPath(path, { baseUrl, resolveUrl, context });
|
|
302
218
|
return `${prefix}${quote}${v ?? path}${quote}`;
|
|
@@ -304,7 +220,7 @@ export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
|
|
|
304
220
|
}
|
|
305
221
|
|
|
306
222
|
export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
|
|
307
|
-
const context =
|
|
223
|
+
const context = closureStamps(resolveUrl);
|
|
308
224
|
return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
|
|
309
225
|
const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
|
|
310
226
|
return `${prefix}${v ?? path}${suffix}`;
|
package/lib/maker.mjs
CHANGED
|
@@ -24,8 +24,8 @@ export const MAKER_LANE =
|
|
|
24
24
|
|
|
25
25
|
export const MAKER_DEV =
|
|
26
26
|
"You are in the Joystack maker: this folder's dev server is already running, and the " +
|
|
27
|
-
'person plays the game on their page, beside the chat. Every file you save shows up ' +
|
|
28
|
-
'there. Nothing was started.';
|
|
27
|
+
'person plays the game on their page, beside the chat (on a phone, under Play). Every file you save shows up ' +
|
|
28
|
+
'there. Nothing was started. There is no link to give them: to hand the game over, tell them it is ready to play on their page.';
|
|
29
29
|
|
|
30
30
|
// `publish` and `land` both do what the page's Publish button does: the draft
|
|
31
31
|
// goes into the game, and the game goes online under the name it has. The
|
|
@@ -61,3 +61,17 @@ export async function makerPublish(at, { slug = null, log = console.log, fetchIm
|
|
|
61
61
|
);
|
|
62
62
|
return body;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
// The port of this draft's dev server on the maker's machine, starting it if
|
|
66
|
+
// it sleeps (`POST <JOYSTACK_MAKER>/serve`, which answers `{ port }`), or null
|
|
67
|
+
// when the maker does not say. A draft's server is on a port of the maker's
|
|
68
|
+
// choosing, which the ports `joystack dev` takes on a laptop do not include.
|
|
69
|
+
export async function makerDevPort(at, { fetchImpl = fetch } = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchImpl(`${at}/serve`, { method: 'POST', signal: AbortSignal.timeout(60_000) });
|
|
72
|
+
const port = res.ok ? Number((await res.json())?.port) : NaN;
|
|
73
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# `joystack shot`
|
|
2
|
+
|
|
3
|
+
Runs a short Playwright script against the running game and writes the
|
|
4
|
+
pictures it takes. It is how an agent sees the game in a particular
|
|
5
|
+
situation: the script holds only the steps that get the game there, and the
|
|
6
|
+
command does the rest, the parts that go wrong when every script does them
|
|
7
|
+
for itself.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx joystack shot .joystack/boss.mjs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
export default async ({ page, game, shot, log }) => {
|
|
15
|
+
await page.selectOption('#jump', '6');
|
|
16
|
+
await game.waitFor((w) => w.query('boss').length > 0);
|
|
17
|
+
await shot('boss'); // .joystack/shots/boss.png
|
|
18
|
+
};
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The function is handed:
|
|
22
|
+
|
|
23
|
+
- `page`: the Playwright page, open on the game and started, at a phone's
|
|
24
|
+
size (390×844 at twice the pixels; `--size tablet|desktop`, `--landscape`),
|
|
25
|
+
drawn as a player sees it (`_render=full`), under a player name of its own
|
|
26
|
+
(in a multiplayer game, in the game's one room: Limits). Each step on it
|
|
27
|
+
waits at most 5 s.
|
|
28
|
+
- `game`: the world handle the engine's `openPage` returns (`joystack/smoke`,
|
|
29
|
+
`shared/docs/api.md`): reads, `set`, `send`, `driveTo`, `waitFor`, the clock.
|
|
30
|
+
Its `waitFor` and `driveTo` wait at most 5 s too, unless the script passes
|
|
31
|
+
its own `timeoutMs`. It is not the game page's `window.game`.
|
|
32
|
+
- `shot(name)`: the page as it is now, with the dev tools left out, written to
|
|
33
|
+
`.joystack/shots/<name>.png` (`--out` for another folder).
|
|
34
|
+
- `log(...)`: a line printed with the command's output.
|
|
35
|
+
|
|
36
|
+
In the maker (`JOYSTACK_MAKER` set) the command ends with the line that shows
|
|
37
|
+
each picture in the agent's reply.
|
|
38
|
+
|
|
39
|
+
## What it does for the script
|
|
40
|
+
|
|
41
|
+
| Step | Where |
|
|
42
|
+
|---|---|
|
|
43
|
+
| The script parses, ends in `.mjs`, exports a function, and does not launch a browser, open a page or an address, or kill processes: checked before anything starts, with the line it refused | `check.mjs` |
|
|
44
|
+
| The dev stack: the one running for the game, in the maker the draft's (its port asked of the maker), or one started with its room server for the length of the command | `shot.mjs` |
|
|
45
|
+
| The page opens only once the dev server says `ready` with nothing pending, and the run is stopped, saying so, when the server moves to another version while the script runs | `ready.mjs`, `shot.mjs` |
|
|
46
|
+
| One script at a time on a computer: a headless browser drawing a 3D game without a GPU takes a whole core, and a maker machine has one | `lock.mjs` |
|
|
47
|
+
| The script runs in a process of its own (`run.mjs`), with the preloads a browser smoke gets, so it can import the joystack doors. The runner stops a script past its limit (`--timeout`, 60 s); a runner stuck past that is ended a minute later, and a Ctrl-C or a signal to the command ends it, its browser, a dev stack started for the command and the lock | `shot.mjs`, `run.mjs` |
|
|
48
|
+
| A failure is said in a line or two: Playwright's first line, what a timeout means, and the line of the script it came from. A game that throws while the script runs fails the run with what it threw, and fixed waits that add up to more than a second earn a line saying what to wait on instead | `run.mjs` |
|
|
49
|
+
|
|
50
|
+
## Limits
|
|
51
|
+
|
|
52
|
+
- A multiplayer game's dev page does not read the `?room=` the page asks for: the script's page joins the game's one room, beside
|
|
53
|
+
anyone playing it, and `game.set`, `pause()` and `setTimeScale()` act on
|
|
54
|
+
that room.
|
|
55
|
+
- One page, one player: a script cannot open a second (the check refuses
|
|
56
|
+
`newPage` and `newContext`). A situation that needs two players is a smoke
|
|
57
|
+
with two `openPage` calls.
|
|
58
|
+
- It cannot stage an entity the game does not spawn (`openPage`'s
|
|
59
|
+
`stage`); `game.set` moves what is there. An entity the game no longer
|
|
60
|
+
spawns is a smoke's, with `openPage({ stage })`.
|
|
61
|
+
- The game counts as started once its world has entities (`openPage`), so a
|
|
62
|
+
game whose start shows none (a title screen with nothing spawned) fails
|
|
63
|
+
with "the game did not start".
|
|
64
|
+
- It opens a page on the game, never the person's own screen. A picture of
|
|
65
|
+
what they see comes from Point's "This moment" in the dev tools.
|
|
66
|
+
|
|
67
|
+
## Files
|
|
68
|
+
|
|
69
|
+
- `shot.mjs`: the door: the arguments, the stack, the lock, the run.
|
|
70
|
+
- `check.mjs`: the check before anything starts.
|
|
71
|
+
- `run.mjs`: the process the script runs in.
|
|
72
|
+
- `ready.mjs`: the dev server's state, and waiting for it to be between updates.
|
|
73
|
+
- `lock.mjs`: one at a time.
|
|
74
|
+
- `shot.test.mjs`: the arguments, the check, the waiting and the lock;
|
|
75
|
+
`packages/cli/shot.smoke.mjs` runs scripts against a real `joystack dev`.
|