@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.
@@ -0,0 +1,22 @@
1
+ # fingerprint
2
+
3
+ A file's address from its content, for the dev server (`static-server.mjs`).
4
+
5
+ - **The stamp**: a URL the server writes (a `<script src>`, an import, an
6
+ import-map target, an injected tag) carries `?v=<hash>`, the hash of the
7
+ file's import closure, so an edit anywhere below a module renames it, and a
8
+ restart of the dev server renames nothing.
9
+ - **Fresh addresses**: a page loaded with `?fresh=<token>` names every
10
+ stamp `?v=<hash>.<token>`, and every module it reaches names its imports
11
+ the same way (a module asked for by its plain URL takes the token from its
12
+ own `?fresh=` or from its referrer). Nothing of such a page comes from the
13
+ browser's cache; a failed start's Reload loads the page so. A page without
14
+ it is named as always.
15
+ - **The headers**: a stamped URL whose `v` is current is kept by the
16
+ browser for good; any other file of the game or the engine is checked with the server (`ETag`, `304`).
17
+ A game's page is never kept.
18
+ - **The start's files**: the stamped closure of a game's start and its
19
+ declared assets, which the maker downloads ahead of Play through the warm
20
+ page (`warm.mjs`, served at `/__joystack/warm/<slug>/`): a page of the dev
21
+ server's own, not a game page, that fetches the list and fetches it again
22
+ on each new version.
@@ -0,0 +1,235 @@
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 fresh token: a page loaded with `?fresh=<n>` names every stamp it
138
+ // carries `?v=<stamp>.<n>`, and so does every module it reaches, so none of
139
+ // its files is taken from the browser's cache. A failed start's Reload loads
140
+ // the page so: a damaged copy kept for good under a stamp cannot be what the
141
+ // next start runs. Letters and digits only, since it is written into pages
142
+ // and modules.
143
+ const FRESH_RE = /^[0-9a-z]{1,16}$/;
144
+ export const freshToken = (value) => (typeof value === 'string' && FRESH_RE.test(value) ? value : null);
145
+
146
+ // A `?v=` value as the stamp it asks for and its fresh token, if any.
147
+ export function splitStamp(value) {
148
+ if (value == null) return { stamp: null, fresh: null };
149
+ const m = /^([0-9a-f]{12})\.([0-9a-z]{1,16})$/.exec(value);
150
+ return m ? { stamp: m[1], fresh: m[2] } : { stamp: value, fresh: null };
151
+ }
152
+
153
+ // A URL's `?v=` value: its stamp, then the fresh token when there is one.
154
+ export const stampValue = (stamp, fresh = null) => (fresh ? `${stamp}.${fresh}` : stamp);
155
+
156
+ // The fresh token of a request for a module asked for by its plain URL (the
157
+ // dev tools' tool files, a computed `import()`): its own `?fresh=`, or else
158
+ // the one its referrer carries, a module of a fresh page or the page itself,
159
+ // on this same host. A browser sends the importing module's URL as a module
160
+ // request's referrer, so a module reached both ways runs once, under fresh
161
+ // addresses, and not twice. A page whose referrer policy keeps the query to
162
+ // itself loses only this fallback.
163
+ export function freshOfRequest(req, query) {
164
+ const own = freshToken(query?.get('fresh'));
165
+ if (own) return own;
166
+ const referer = req?.headers?.referer;
167
+ if (!referer) return null;
168
+ let url;
169
+ try { url = new URL(referer); } catch { return null; }
170
+ if (url.host !== String(req.headers.host ?? '')) return null;
171
+ return freshToken(url.searchParams.get('fresh')) ?? splitStamp(url.searchParams.get('v')).fresh;
172
+ }
173
+
174
+ // A stamped address in a page: in quotes, ending in `?v=<stamp>`.
175
+ const STAMPED_RE = /["']([^"'\s<>]+)\?v=[0-9a-f]{12}["']/g;
176
+
177
+ // The files a game's start downloads, each under the stamped URL it is asked
178
+ // for by: every module of the closure of each stamped address the served page
179
+ // names (its scripts, its import map, the injected tags, the skeleton's
180
+ // entity modules), literal dynamic imports included. `html` is the page as the
181
+ // dev server serves it, `pageUrl` its URL. `plain` names modules the page asks
182
+ // for under their plain URLs (the dev tools' tool files, whose names the
183
+ // runtime reads from the file's): each is listed as it is, and what it
184
+ // imports, stamped.
185
+ export function startFiles({ html, pageUrl, resolveUrl, plain = [] }) {
186
+ const stamps = closureStamps(resolveUrl);
187
+ const files = new Set();
188
+ const roots = [];
189
+ for (const m of String(html).matchAll(STAMPED_RE)) {
190
+ const root = resolveSpecToUrl(m[1], pageUrl, { allowBareRelative: true });
191
+ if (root) roots.push(root);
192
+ }
193
+ for (const url of plain) {
194
+ if (!stamps.node(url)) continue;
195
+ files.add(url);
196
+ roots.push(url);
197
+ }
198
+ for (const root of roots) {
199
+ for (const url of stamps.closure(root)) {
200
+ if (plain.includes(url)) continue;
201
+ // Only these are imported by stamped URLs (inject.mjs, versionedPath).
202
+ if (!/\.(js|mjs|json)$/.test(url)) continue;
203
+ const stamp = stamps.stamp(url);
204
+ if (stamp) files.add(`${url}?v=${stamp}`);
205
+ }
206
+ }
207
+ return [...files];
208
+ }
209
+
210
+ // The headers for a file the dev server sends (a module, a model, a sound,
211
+ // data), never for a game's page, which is always fetched fresh. `requested`
212
+ // is the stamp the URL carries (`?v=`), `current` the file's stamp now. Only
213
+ // a URL whose stamp is current is kept for good: an older page asking for an
214
+ // address whose file has since changed gets the new bytes, which must not be
215
+ // kept under the old address. A fresh address (`requested` without its
216
+ // token, splitStamp) of the current bytes is kept the same way: a damaged
217
+ // copy under it lasts until the next Reload picks another token. Everything
218
+ // else carries a tag of its bytes and
219
+ // is checked with the server each time, a round trip and no download when it
220
+ // has not changed. `private`: a maker live link serves only its owner.
221
+ export function cacheHeaders({ requested, current, body }) {
222
+ if (requested && current && requested === current) {
223
+ return { 'Cache-Control': 'private, max-age=31536000, immutable' };
224
+ }
225
+ return { 'Cache-Control': 'no-cache', ETag: `"${hashOf(body)}"` };
226
+ }
227
+
228
+ // Whether a request already holds these bytes (its If-None-Match names the
229
+ // tag, weak or strong, alone or in a list).
230
+ export function notModified(req, etag) {
231
+ const header = req.headers?.['if-none-match'];
232
+ if (!header || !etag) return false;
233
+ return header.split(',').some((t) => t.trim().replace(/^W\//, '') === etag);
234
+ }
235
+ 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, stampValue } 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,25 +98,75 @@ 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
 
105
109
  // The runtime's tag: the overlay's and Point's doors as lazy imports, and
106
- // the tool files. `point`: the engine has Point. `shared`: the page is
107
- // reached through a share link (the maker's live link), where Point is the
108
- // 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' : ''}` : '';
110
+ // the tool files. `point`: the engine has Point. Whether a pick is taken is
111
+ // the host's to say (the overlay asks this server, `server.picks`).
112
+ // The tool files keep their plain URLs: the runtime reads a tool's name from
113
+ // its file's (shared/ui/devtools/game-tools.js), and a stamp would end it.
114
+ // `fresh`: the page's fresh token (fingerprint/), which the runtime adds to
115
+ // each tool file's URL as `?fresh=` when it imports it. `report`: where the
116
+ // runtime posts a failed start (static-server.mjs, START_FAILED_PATH).
117
+ function devtoolsTag(engineTools, gameTools, { point = false, stamp = (url) => url, fresh = null, report = null } = {}) {
118
+ const pointing = point ? `, point: () => import("${stamp(POINT_URL)}")` : '';
111
119
  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>`;
120
+ const extra = `${fresh && engineTools.length + gameTools.length ? `, fresh: ${jsJson(fresh)}` : ''}${report ? `, report: ${jsJson(report)}` : ''}`;
121
+ return `<script type="module">import { startDevtools } from "${stamp(DEVTOOLS_URL)}"; startDevtools({ overlay: () => import("${stamp(DEVTOOLS_OVERLAY_URL)}")${pointing}${files}${extra} });</script>`;
122
+ }
123
+
124
+ // A page served through `joystack dev --share`'s tunnel: the hostname it was
125
+ // shared on, which the engine's isSharedDevPage compares with the page's own
126
+ // before treating a public name as a dev page. Ahead of every module script,
127
+ // like the skeleton, because the room transport reads it as the game boots.
128
+ // Then the game's slug.
129
+ function pageNames(shareHost, slug) {
130
+ const tags = [];
131
+ if (shareHost) tags.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
132
+ if (slug) tags.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
133
+ return tags;
134
+ }
135
+
136
+ // The platform layer's tag, then the old toolbox's when `toolboxUrl` is
137
+ // given. Dev-only, opt-in per request: the server asks for the toolbox only
138
+ // when the resolved engine bundle carries the module (inject-if-present), so
139
+ // an older pinned engine under a newer CLI never gets a 404ing tag. Behind
140
+ // the platform tag: the boot gate's error handlers come up first.
141
+ function platformTags(platformUrl, toolboxUrl) {
142
+ const tags = [`<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`];
143
+ if (toolboxUrl) tags.push(`<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`);
144
+ return tags;
113
145
  }
114
146
 
115
147
  const devFlag = (replay) => `<script>window.JOYSTACK_DEV = ${jsJson({ dev: true, tools: true, replay })};</script>`;
116
148
 
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 } = {}) {
149
+ // The skeleton with each entity module named by its stamped URL. startGame()
150
+ // imports the kinds by these names, relative to the page, while the game's
151
+ // own modules import the same files by their stamped URLs: under two URLs a
152
+ // module would run twice, and the kinds it defines would not be the ones the
153
+ // world spawns. A module the server cannot find keeps its name. `pageUrl` is
154
+ // the page's own URL, which the skeleton's module names are relative to.
155
+ export function stampedKinds(skeleton, pageUrl, stamp) {
156
+ if (!skeleton?.kinds || pageUrl == null) return skeleton;
157
+ const kinds = skeleton.kinds.map((k) => {
158
+ const url = typeof k.module === 'string' ? resolveSpecToUrl(k.module, pageUrl, { allowBareRelative: true }) : null;
159
+ const stamped = url ? stamp(url) : null;
160
+ return stamped && stamped !== url ? { ...k, module: k.module + stamped.slice(url.length) } : k;
161
+ });
162
+ return { ...skeleton, kinds };
163
+ }
164
+
165
+ // `stamp(url)` gives an engine module's URL its stamp (fingerprint/), so the
166
+ // modules the page names here have the one URL the modules that import them
167
+ // name them by, and the browser keeps them while they do not change. Without
168
+ // it they are named as they are.
169
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, engineTools = [], gameTools = [], point = false, skeleton = null, shareHost = null, replay, stamp = (url) => url, fresh, report } = {}) {
118
170
  const parts = [];
119
171
  const tools = devtools ? gameTools : [];
120
172
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
@@ -122,15 +174,10 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
122
174
  // any module script (the platform tag AND game.js): a browser ignores an import
123
175
  // map once a module has begun executing, and startGame() reads the skeleton at
124
176
  // module-eval time. So they go FIRST.
125
- const map = importMap(!!skeleton, tools.length > 0);
177
+ const map = importMap(!!skeleton, tools.length > 0, stamp);
126
178
  if (map) parts.push(map);
127
179
  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>`);
180
+ parts.push(...pageNames(shareHost, slug));
134
181
  // Every page this server serves is a dev page carrying the dev tools: the
135
182
  // engine's one page-side dev answer (`devPage`, shared/ui/room/lib/dev-host.js)
136
183
  // reads it, ahead of every module. `replay`: the page is the creator's own,
@@ -138,146 +185,20 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
138
185
  // may not.
139
186
  parts.push(devFlag(replay !== false));
140
187
  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
- }
188
+ parts.push(...platformTags(stamp(platformUrl), toolbox && stamp(toolboxUrl)));
153
189
  // Inject-if-present: only an engine that carries the runtime gets its tag.
154
- if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost }));
190
+ if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, stamp, fresh, report }));
155
191
  const inject = '\n ' + parts.join('\n ');
156
192
  const m = /<head[^>]*>/i.exec(html);
157
193
  if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
158
194
  return parts.join('\n') + '\n' + html;
159
195
  }
160
196
 
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
197
  // Match <script src="X"> in HTML.
164
198
  const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
165
199
 
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
- // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
200
+ // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null; with a
201
+ // fresh token (fingerprint/), `?v=<closure-stamp>.<token>`. Stamped
281
202
  // extensions include .json: entity/config data is imported by games, and its
282
203
  // URL must rename on edit exactly like a module's.
283
204
  //
@@ -285,28 +206,27 @@ function closureStampUrl(urlPath, context) {
285
206
  // disk-space fallback. Resolving a relative spec beside the importer on disk
286
207
  // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
287
208
  // which silently re-opens the staleness hole. No serving context → no stamp.
288
- function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
209
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context, fresh = null }) {
289
210
  if (!/\.(js|mjs|json)$/.test(relPath)) return null;
290
211
  if (baseUrl == null || !resolveUrl) return null;
291
212
  const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
292
213
  if (!urlTarget) return null;
293
- if (!context.node(urlTarget)) return null;
294
- const stamp = closureStampUrl(urlTarget, context);
295
- return stamp === null ? null : `${relPath}?v=${stamp}`;
214
+ const stamp = context.stamp(urlTarget);
215
+ return stamp === null ? null : `${relPath}?v=${stampValue(stamp, fresh)}`;
296
216
  }
297
217
 
298
- export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
299
- const context = rewriteContext(resolveUrl);
218
+ export function rewriteJsImports(text, { baseUrl, resolveUrl, fresh = null }) {
219
+ const context = closureStamps(resolveUrl);
300
220
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
301
- const v = versionedPath(path, { baseUrl, resolveUrl, context });
221
+ const v = versionedPath(path, { baseUrl, resolveUrl, context, fresh });
302
222
  return `${prefix}${quote}${v ?? path}${quote}`;
303
223
  });
304
224
  }
305
225
 
306
- export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
307
- const context = rewriteContext(resolveUrl);
226
+ export function rewriteHtmlScripts(html, { baseUrl, resolveUrl, fresh = null }) {
227
+ const context = closureStamps(resolveUrl);
308
228
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
309
- const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
229
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context, fresh });
310
230
  return `${prefix}${v ?? path}${suffix}`;
311
231
  });
312
232
  }
package/lib/lint.mjs CHANGED
@@ -22,6 +22,7 @@ import { join, relative } from 'node:path';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { isFrameworkV2 } from './primitives.mjs';
24
24
  import { findProject, resolveEngine } from './project.mjs';
25
+ import { ensureEngine, ensureNamedEngine, namedEngineFailure } from './engine.mjs';
25
26
 
26
27
  const CONFIG_NAMES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
27
28
 
@@ -163,9 +164,23 @@ export async function lintCmd({
163
164
  loadEntityLint = loadEntityLintFromEngine,
164
165
  loadBuildGate = loadBuildGateFromEngine,
165
166
  loadHandbookCheck = loadHandbookCheckFromEngine,
167
+ ensure = ensureEngine,
166
168
  } = {}) {
167
169
  const project = findProject(cwd);
168
170
 
171
+ // Every check below loads its code out of the engine, so it has to be the
172
+ // engine the game names (engine.mjs, ensureNamedEngine). When that one cannot
173
+ // be installed the game is not checked at all: a lint run on another engine
174
+ // is the false green this exists to prevent.
175
+ try {
176
+ await ensureNamedEngine(project.dir, { ensure, log });
177
+ } catch (e) {
178
+ const error = namedEngineFailure(project.dir, e);
179
+ log(`❌ joystack lint: ${error}`);
180
+ log(' Your game was not checked: lint checks it on the engine it names, and no other.');
181
+ return { ok: false, error };
182
+ }
183
+
169
184
  // The entity graph check runs regardless of the eslint tooling below — a
170
185
  // missing dev tool must never switch off a structural guarantee. It skips
171
186
  // itself silently for games with no entity folders.