@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.
@@ -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 busts Chrome's
11
- // sticky module cache on edit.
12
- import { readFileSync, statSync } from 'node:fs';
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
- function devtoolsTag(engineTools, gameTools, { point = false, shared = false } = {}) {
110
- const pointing = point ? `, point: () => import("${POINT_URL}")${shared ? ', pointAlone: false' : ''}` : '';
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
- 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 } = {}) {
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
- // A page served through `joystack dev --share`'s tunnel: the hostname it was
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
- if (!context.node(urlTarget)) return null;
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 = rewriteContext(resolveUrl);
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 = rewriteContext(resolveUrl);
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`.