@joystack/cli 0.1.53 → 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,38 +7,46 @@
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.
17
19
  export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
18
20
 
19
21
  export const PLATFORM_URL = '/shared/platform/platform.js';
20
- // The dev toolbox — injected ONLY here, never by the production serve path
21
- // (backend/api/src/lib/inject.js). That separation is the gate
22
+ // The old dev toolbox, which an engine from before the dev tools runtime
23
+ // carries (0.2.46 and earlier) — injected ONLY here, never by the production
24
+ // serve path (backend/api/src/lib/inject.js). That separation is the gate
22
25
  // that keeps a dev surface off published pages: the production injector never
23
26
  // names this module, so there is nothing to strip and nothing to trust.
24
27
  export const TOOLBOX_URL = '/shared/ui/toolbox/toolbox.js';
25
28
  // The dev tools (shared/ui/devtools/): the runtime that runs a game's tools
26
29
  // and talks to whatever draws them, the maker around the game or the
27
- // overlay over it. Dev-only, like the toolbox. The overlay is named here and
30
+ // overlay over it. Dev-only. The overlay is named here and
28
31
  // imported only when the runtime finds no page around the game to draw.
29
32
  export const DEVTOOLS_URL = '/shared/ui/devtools/runtime.js';
33
+ // The engine's list of its own dev tools: { tools: [{ url, devServer }] }.
34
+ export const ENGINE_TOOLS_URL = '/shared/ui/devtools/engine-tools.json';
30
35
  export const DEVTOOLS_OVERLAY_URL = '/shared/ui/devtools/overlay/overlay.js';
36
+ // Point (shared/ui/pointing/), which the runtime loads when a host turns it
37
+ // on; named only when the engine has it.
38
+ export const POINT_URL = '/shared/ui/pointing/pointing.js';
31
39
  // What a game's own tool files (`tools/<name>.tool.js`) import as
32
40
  // `joystack/tools`. Dev pages only: a published page never loads a tool, so
33
41
  // the production import map (V2_BARE_SPECIFIERS) has no such door.
34
42
  export const DEVTOOLS_DOOR = { 'joystack/tools': '/shared/ui/devtools/devtools.js' };
35
43
  // The laptop note (shared/ui/note/): the conversation listener a page carries
36
- // when the dev server was started by a coding-agent session. Dev-only, like
37
- // the toolbox, and on every page the creator opens, not only the game.
44
+ // when the dev server was started by a coding-agent session. Dev-only, and on
45
+ // every page the creator opens, not only the game.
38
46
  export const NOTE_URL = '/shared/ui/note/note.js';
39
47
 
40
48
  // The note's tag, last in <head> so it comes up after the platform layer and
41
- // the toolbox; a page without a <head> gets it first.
49
+ // the dev tools; a page without a <head> gets it first.
42
50
  export function injectNoteTag(html, noteUrl = NOTE_URL) {
43
51
  const tag = `<script type="module">import { installNote } from "${noteUrl}"; installNote();</script>`;
44
52
  const end = /<\/head>/i.exec(html);
@@ -90,13 +98,72 @@ function setGlobal(scope, name, value) {
90
98
  // The page's import map: the framework's doors for a v2 game, and
91
99
  // `joystack/tools` when the game has tool files, which import it. A v1 page
92
100
  // with tools gets a map of that door alone; a page with neither, none.
93
- function importMap(v2, tools) {
101
+ function importMap(v2, tools, stamp) {
94
102
  if (!v2 && !tools) return '';
95
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);
96
106
  return `<script type="importmap">${jsJson({ imports })}</script>`;
97
107
  }
98
108
 
99
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, gameTools = [], skeleton = null, shareHost = null } = {}) {
109
+ // The runtime's tag: the overlay's and Point's doors as lazy imports, and
110
+ // the tool files. `point`: the engine has Point. `shared`: the page is
111
+ // reached through a share link (the maker's live link), where Point is the
112
+ // maker's only: opened alone, nobody hears a pick.
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' : ''}` : '';
117
+ const files = `${engineTools.length ? `, engineTools: ${jsJson(engineTools)}` : ''}${gameTools.length ? `, gameTools: ${jsJson(gameTools)}` : ''}`;
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;
142
+ }
143
+
144
+ const devFlag = (replay) => `<script>window.JOYSTACK_DEV = ${jsJson({ dev: true, tools: true, replay })};</script>`;
145
+
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 } = {}) {
100
167
  const parts = [];
101
168
  const tools = devtools ? gameTools : [];
102
169
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
@@ -104,160 +171,29 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
104
171
  // any module script (the platform tag AND game.js): a browser ignores an import
105
172
  // map once a module has begun executing, and startGame() reads the skeleton at
106
173
  // module-eval time. So they go FIRST.
107
- const map = importMap(!!skeleton, tools.length > 0);
174
+ const map = importMap(!!skeleton, tools.length > 0, stamp);
108
175
  if (map) parts.push(map);
109
176
  if (skeleton) parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
110
- // A page served through `joystack dev --share`'s tunnel: the hostname it was
111
- // shared on, which the engine's isSharedDevPage compares with the page's own
112
- // before treating a public name as a dev page. Ahead of every module script,
113
- // like the skeleton, because the room transport reads it as the game boots.
114
- if (shareHost) parts.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
115
- if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
177
+ parts.push(...pageNames(shareHost, slug));
178
+ // Every page this server serves is a dev page carrying the dev tools: the
179
+ // engine's one page-side dev answer (`devPage`, shared/ui/room/lib/dev-host.js)
180
+ // reads it, ahead of every module. `replay`: the page is the creator's own,
181
+ // and may play a recording back or stage entities; a tester's share page
182
+ // may not.
183
+ parts.push(devFlag(replay !== false));
116
184
  parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
117
- parts.push(
118
- `<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
119
- );
120
- // Dev-only, opt-in per request: the server sets `toolbox` only when the
121
- // resolved engine bundle carries the module (inject-if-present), so an older
122
- // pinned engine under a newer CLI never gets a 404ing tag. Behind the
123
- // platform tag — the boot gate's error handlers come up first.
124
- if (toolbox) {
125
- parts.push(
126
- `<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
127
- );
128
- }
129
- // Inject-if-present like the toolbox: only an engine that carries the
130
- // runtime gets its tag.
131
- if (devtools) {
132
- parts.push(
133
- `<script type="module">import { startDevtools } from "${DEVTOOLS_URL}"; startDevtools({ overlay: () => import("${DEVTOOLS_OVERLAY_URL}")${tools.length ? `, gameTools: ${jsJson(tools)}` : ''} });</script>`,
134
- );
135
- }
185
+ parts.push(...platformTags(stamp(platformUrl), toolbox && stamp(toolboxUrl)));
186
+ // Inject-if-present: only an engine that carries the runtime gets its tag.
187
+ if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost, stamp }));
136
188
  const inject = '\n ' + parts.join('\n ');
137
189
  const m = /<head[^>]*>/i.exec(html);
138
190
  if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
139
191
  return parts.join('\n') + '\n' + html;
140
192
  }
141
193
 
142
- // Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
143
- const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
144
194
  // Match <script src="X"> in HTML.
145
195
  const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
146
196
 
147
- // A spec that can never name a local file the server would stamp: absolute
148
- // URLs, data:/blob:, and anything already carrying a query or fragment.
149
- function isExternalSpec(spec) {
150
- return (
151
- /^(https?:)?\/\//.test(spec) ||
152
- spec.startsWith('data:') ||
153
- spec.startsWith('blob:') ||
154
- spec.includes('?') ||
155
- spec.includes('#')
156
- );
157
- }
158
-
159
- // Resolve an import specifier the way the BROWSER does — against the importing
160
- // module's URL — returning an absolute URL path, or null for bare/external
161
- // specs. Stamping must live in URL space because that is where module identity
162
- // lives: an engine file's relative sibling import can be shadowed by the
163
- // game's overrides mount, so the file the URL serves is not the file that sits
164
- // next to the importer on disk.
165
- function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
166
- if (isExternalSpec(spec)) return null;
167
- if (spec.startsWith('/')) return spec;
168
- if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
169
- try {
170
- return new URL(spec, 'http://x' + baseUrl).pathname;
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- // The version stamp is the max mtime over the module's TRANSITIVE static
177
- // import closure, not the file's own mtime. Module identity is the full URL,
178
- // and a browser may reuse a cached body for an unchanged URL across reloads
179
- // (Chrome's memory cache does not honor no-store for same-URL subresources).
180
- // A per-target stamp renames only the edited file's URL — its importers' URLs
181
- // stay put, so a reloading page reassembles the whole pre-edit graph from
182
- // cache with zero requests and no error anywhere. Stamping the closure renames
183
- // every URL on the path from the entry (which a navigation always re-fetches),
184
- // so no URL-keyed cache can serve stale bytes.
185
- //
186
- // The parse cache stores RAW specifiers keyed by file path + mtime; resolution
187
- // happens per walk, because the same file's specs resolve differently through
188
- // different mount tables (two servers in one process must not contaminate each
189
- // other). Boundary: only string-literal static/dynamic imports are seen —
190
- // import(expr) and new URL(...) are runtime-computed and stay unstamped.
191
- const parseCache = new Map(); // abs fs path -> { mtimeMs, specs: string[] }
192
-
193
- function fileInfo(path) {
194
- let st;
195
- try {
196
- st = statSync(path);
197
- } catch {
198
- return null;
199
- }
200
- const hit = parseCache.get(path);
201
- if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
202
- const specs = [];
203
- if (/\.(js|mjs)$/.test(path)) {
204
- let text = null;
205
- try {
206
- text = readFileSync(path, 'utf8');
207
- } catch {
208
- /* unreadable: stamp from its own mtime alone */
209
- }
210
- if (text !== null) {
211
- for (const m of text.matchAll(JS_IMPORT_RE)) {
212
- if (!isExternalSpec(m[3])) specs.push(m[3]);
213
- }
214
- }
215
- }
216
- const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
217
- parseCache.set(path, info);
218
- return info;
219
- }
220
-
221
- // A rewrite shares resolved nodes across all import closures. Each URL is
222
- // resolved/stat'ed once even when several roots reach it. The cache belongs to
223
- // this synchronous rewrite only: the next request observes edits, deletions and
224
- // newly-created overrides immediately, without waiting for the reload watcher.
225
- function rewriteContext(resolveUrl) {
226
- const nodes = new Map();
227
- return {
228
- stamps: new Map(),
229
- node(url) {
230
- if (!nodes.has(url)) {
231
- const path = resolveUrl(url);
232
- nodes.set(url, path ? fileInfo(path) : null);
233
- }
234
- return nodes.get(url);
235
- },
236
- };
237
- }
238
-
239
- function closureStampUrl(urlPath, context) {
240
- if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
241
- let max = 0;
242
- const seen = new Set();
243
- const stack = [urlPath];
244
- while (stack.length) {
245
- const url = stack.pop();
246
- if (seen.has(url)) continue;
247
- seen.add(url);
248
- const info = context.node(url);
249
- if (!info) continue;
250
- max = Math.max(max, info.mtimeMs);
251
- for (const spec of info.specs) {
252
- const child = resolveSpecToUrl(spec, url);
253
- if (child) stack.push(child);
254
- }
255
- }
256
- const stamp = max ? Math.floor(max) : null;
257
- context.stamps.set(urlPath, stamp);
258
- return stamp;
259
- }
260
-
261
197
  // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
262
198
  // extensions include .json: entity/config data is imported by games, and its
263
199
  // URL must rename on edit exactly like a module's.
@@ -271,13 +207,12 @@ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false
271
207
  if (baseUrl == null || !resolveUrl) return null;
272
208
  const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
273
209
  if (!urlTarget) return null;
274
- if (!context.node(urlTarget)) return null;
275
- const stamp = closureStampUrl(urlTarget, context);
210
+ const stamp = context.stamp(urlTarget);
276
211
  return stamp === null ? null : `${relPath}?v=${stamp}`;
277
212
  }
278
213
 
279
214
  export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
280
- const context = rewriteContext(resolveUrl);
215
+ const context = closureStamps(resolveUrl);
281
216
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
282
217
  const v = versionedPath(path, { baseUrl, resolveUrl, context });
283
218
  return `${prefix}${quote}${v ?? path}${quote}`;
@@ -285,7 +220,7 @@ export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
285
220
  }
286
221
 
287
222
  export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
288
- const context = rewriteContext(resolveUrl);
223
+ const context = closureStamps(resolveUrl);
289
224
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
290
225
  const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
291
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
+ }
package/lib/publish.mjs CHANGED
@@ -47,7 +47,7 @@ export async function publish({
47
47
  const project = findProject(cwd);
48
48
 
49
49
  // `--dev-tools` publishes a REVIEW BUILD: the published page carries the dev
50
- // toolbox, so whoever opens the link can use the game's Tweaks. That belongs on
50
+ // tools, so whoever opens the link can use the game's tools. That belongs on
51
51
  // a copy made for reviewing (a pull request's link, a lane's), never on the
52
52
  // game players open — so it needs its own --slug, and the game's own slug is
53
53
  // refused outright, and so is the slug of the game a lane was opened from.
@@ -55,7 +55,7 @@ export async function publish({
55
55
  if (devTools && (!slug || slug === project.slug || slug === laneParentSlug(project.dir))) {
56
56
  throw new Error(
57
57
  `--dev-tools publishes a review build, and a review build never replaces the live game.\n\n` +
58
- ` npx joystack publish --dev-tools --slug ${project.slug}-review # a review copy with Tweaks\n` +
58
+ ` npx joystack publish --dev-tools --slug ${project.slug}-review # a review copy with dev tools\n` +
59
59
  ` npx joystack publish # the live game, without dev tools`,
60
60
  );
61
61
  }
@@ -183,7 +183,7 @@ export async function publish({
183
183
  out = await publishInOneRequest({ apiBase, token, files, meta });
184
184
  }
185
185
  const url = playUrlFor(out.slug);
186
- log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
186
+ log(devTools ? `✅ Live (review build, dev tools on): ${url}` : `✅ Live: ${url}`);
187
187
  return { slug: out.slug, url };
188
188
  }
189
189
 
@@ -30,7 +30,7 @@ export const DEV_SEGMENT_PATH = '/__joystack/segments';
30
30
 
31
31
  // Where the dev page asks what recordings exist, deletes one, or pins one
32
32
  // against the prune — the read half of the same lane. Spelled out in the
33
- // engine's Sessions tool too (shared/ui/sessions/sessions.js), for the same
33
+ // engine's Sessions tool too (shared/ui/sessions/sessions.tool.js), for the same
34
34
  // reason as above: two ends of one wire, neither able to drift silently.
35
35
  export const DEV_REPLAYS_PATH = '/__joystack/replays';
36
36
 
@@ -713,7 +713,7 @@ export function pruneOnStart({ projectDir, log = () => {}, caps } = {}) {
713
713
  // went for a reason that was not the reason.
714
714
  log(`→ replays swept ${result.removed.length} old recording(s) — keeping the newest `
715
715
  + `${result.caps.maxCount} and up to ${sizeLabel(result.caps.maxBytes)} `
716
- + "(pin one in the toolbox's Sessions tool to keep it)");
716
+ + '(Keep one in Sessions, in the dev tools, to hold on to it)');
717
717
  }
718
718
  return result;
719
719
  }