@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.
- package/CHANGELOG.md +52 -0
- package/bin/joystack.mjs +40 -2
- package/lib/assets-export.mjs +198 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +15 -5
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +15 -10
- package/lib/fingerprint/README.md +16 -0
- package/lib/fingerprint/fingerprint.mjs +195 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +92 -157
- package/lib/maker.mjs +16 -2
- package/lib/publish.mjs +3 -3
- package/lib/replay-store.mjs +2 -2
- package/lib/shot/README.md +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/static-server.mjs +135 -33
- package/lib/test-cmd.mjs +29 -12
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// A file's address from its content: the stamp, the cache headers and the
|
|
2
|
+
// files a game's start needs. See README.md.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
|
|
7
|
+
export const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
|
|
8
|
+
|
|
9
|
+
// A spec that can never name a local file the server would stamp: absolute
|
|
10
|
+
// URLs, data:/blob:, and anything already carrying a query or fragment.
|
|
11
|
+
export function isExternalSpec(spec) {
|
|
12
|
+
return (
|
|
13
|
+
/^(https?:)?\/\//.test(spec) ||
|
|
14
|
+
spec.startsWith('data:') ||
|
|
15
|
+
spec.startsWith('blob:') ||
|
|
16
|
+
spec.includes('?') ||
|
|
17
|
+
spec.includes('#')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Resolve an import specifier the way the BROWSER does — against the importing
|
|
22
|
+
// module's URL — returning an absolute URL path, or null for bare/external
|
|
23
|
+
// specifiers. Stamping lives in URL space because that is where module identity
|
|
24
|
+
// lives: an engine file's relative sibling import can be shadowed by the
|
|
25
|
+
// game's overrides mount, so the file the URL serves is not the file that sits
|
|
26
|
+
// next to the importer on disk.
|
|
27
|
+
export function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
|
|
28
|
+
if (isExternalSpec(spec)) return null;
|
|
29
|
+
if (spec.startsWith('/')) return spec;
|
|
30
|
+
if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
|
|
31
|
+
try {
|
|
32
|
+
return new URL(spec, 'http://x' + baseUrl).pathname;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hashOf = (bytes) => createHash('sha1').update(bytes).digest('hex');
|
|
39
|
+
|
|
40
|
+
// What a file is, for its stamp: a hash of its bytes and, for a module, the
|
|
41
|
+
// RAW specifiers it imports. Kept by path and re-read when its time, change
|
|
42
|
+
// time or size moves. Specifiers resolve per walk, because the same file's
|
|
43
|
+
// specifiers resolve differently through different mount tables (two
|
|
44
|
+
// servers in one process must not contaminate each other). Only
|
|
45
|
+
// string-literal static and dynamic imports are seen: import(expr) and
|
|
46
|
+
// new URL(...) are computed at run time and stay unstamped.
|
|
47
|
+
const fileCache = new Map(); // abs fs path -> { mtimeMs, ctimeMs, size, hash, specs }
|
|
48
|
+
const RACY_MS = 2000;
|
|
49
|
+
|
|
50
|
+
function fileInfo(path) {
|
|
51
|
+
let st;
|
|
52
|
+
try {
|
|
53
|
+
st = statSync(path);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!st.isFile()) return null;
|
|
58
|
+
const hit = fileCache.get(path);
|
|
59
|
+
// A file written in the last moments is read again: a second write of the
|
|
60
|
+
// same size inside the clock's tick (coarse on some disks, Windows among
|
|
61
|
+
// them) leaves its times and size as they were, and its old hash would
|
|
62
|
+
// stand.
|
|
63
|
+
const settled = Date.now() - Math.max(st.mtimeMs, st.ctimeMs) > RACY_MS;
|
|
64
|
+
if (hit && settled && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
|
|
65
|
+
let bytes;
|
|
66
|
+
try {
|
|
67
|
+
bytes = readFileSync(path);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const specs = [];
|
|
72
|
+
if (/\.(js|mjs)$/.test(path)) {
|
|
73
|
+
for (const m of bytes.toString('utf8').matchAll(JS_IMPORT_RE)) {
|
|
74
|
+
if (!isExternalSpec(m[3])) specs.push(m[3]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, hash: hashOf(bytes), specs };
|
|
78
|
+
fileCache.set(path, info);
|
|
79
|
+
return info;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The stamps of one request: a URL's stamp is a hash of every file in its
|
|
83
|
+
// transitive import closure, each by its URL and its content. Module identity
|
|
84
|
+
// is the full URL, and a browser reuses a body it holds for an unchanged URL,
|
|
85
|
+
// so an edit must rename every URL on the path from the entry down: a stamp of
|
|
86
|
+
// the file alone would leave its importers' URLs, and the whole pre-edit
|
|
87
|
+
// graph, in the browser's hands. Made of content and not of file times, a
|
|
88
|
+
// stamp survives a copy of the game (a dev server freezes one at start) and a
|
|
89
|
+
// restart, so a browser keeps what has not changed.
|
|
90
|
+
//
|
|
91
|
+
// Resolved nodes and stamps are shared across the closures of one request and
|
|
92
|
+
// never kept past it: the next request sees edits, deletions and newly made
|
|
93
|
+
// overrides at once, without waiting for the reload watcher.
|
|
94
|
+
export function closureStamps(resolveUrl) {
|
|
95
|
+
const nodes = new Map();
|
|
96
|
+
const stamps = new Map();
|
|
97
|
+
const node = (url) => {
|
|
98
|
+
if (!nodes.has(url)) {
|
|
99
|
+
const path = resolveUrl(url);
|
|
100
|
+
nodes.set(url, path ? fileInfo(path) : null);
|
|
101
|
+
}
|
|
102
|
+
return nodes.get(url);
|
|
103
|
+
};
|
|
104
|
+
// The URLs of the files a URL's closure holds, itself first; a file that
|
|
105
|
+
// cannot be found is left out.
|
|
106
|
+
const closure = (urlPath) => {
|
|
107
|
+
const members = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const stack = [urlPath];
|
|
110
|
+
while (stack.length) {
|
|
111
|
+
const url = stack.pop();
|
|
112
|
+
if (seen.has(url)) continue;
|
|
113
|
+
seen.add(url);
|
|
114
|
+
const info = node(url);
|
|
115
|
+
if (!info) continue;
|
|
116
|
+
members.push(url);
|
|
117
|
+
for (const spec of info.specs) {
|
|
118
|
+
const child = resolveSpecToUrl(spec, url);
|
|
119
|
+
if (child) stack.push(child);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return members;
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
node,
|
|
126
|
+
closure,
|
|
127
|
+
stamp(urlPath) {
|
|
128
|
+
if (stamps.has(urlPath)) return stamps.get(urlPath);
|
|
129
|
+
const members = closure(urlPath).map((url) => `${url}\0${node(url).hash}`);
|
|
130
|
+
const stamp = members.length ? hashOf(members.sort().join('\n')).slice(0, 12) : null;
|
|
131
|
+
stamps.set(urlPath, stamp);
|
|
132
|
+
return stamp;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A stamped address in a page: in quotes, ending in `?v=<stamp>`.
|
|
138
|
+
const STAMPED_RE = /["']([^"'\s<>]+)\?v=[0-9a-f]{12}["']/g;
|
|
139
|
+
|
|
140
|
+
// The files a game's start downloads, each under the stamped URL it is asked
|
|
141
|
+
// for by: every module of the closure of each stamped address the served page
|
|
142
|
+
// names (its scripts, its import map, the injected tags, the skeleton's
|
|
143
|
+
// entity modules), literal dynamic imports included. `html` is the page as the
|
|
144
|
+
// dev server serves it, `pageUrl` its URL. `plain` names modules the page asks
|
|
145
|
+
// for under their plain URLs (the dev tools' tool files, whose names the
|
|
146
|
+
// runtime reads from the file's): each is listed as it is, and what it
|
|
147
|
+
// imports, stamped.
|
|
148
|
+
export function startFiles({ html, pageUrl, resolveUrl, plain = [] }) {
|
|
149
|
+
const stamps = closureStamps(resolveUrl);
|
|
150
|
+
const files = new Set();
|
|
151
|
+
const roots = [];
|
|
152
|
+
for (const m of String(html).matchAll(STAMPED_RE)) {
|
|
153
|
+
const root = resolveSpecToUrl(m[1], pageUrl, { allowBareRelative: true });
|
|
154
|
+
if (root) roots.push(root);
|
|
155
|
+
}
|
|
156
|
+
for (const url of plain) {
|
|
157
|
+
if (!stamps.node(url)) continue;
|
|
158
|
+
files.add(url);
|
|
159
|
+
roots.push(url);
|
|
160
|
+
}
|
|
161
|
+
for (const root of roots) {
|
|
162
|
+
for (const url of stamps.closure(root)) {
|
|
163
|
+
if (plain.includes(url)) continue;
|
|
164
|
+
// Only these are imported by stamped URLs (inject.mjs, versionedPath).
|
|
165
|
+
if (!/\.(js|mjs|json)$/.test(url)) continue;
|
|
166
|
+
const stamp = stamps.stamp(url);
|
|
167
|
+
if (stamp) files.add(`${url}?v=${stamp}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...files];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The headers for a file the dev server sends (a module, a model, a sound,
|
|
174
|
+
// data), never for a game's page, which is always fetched fresh. `requested`
|
|
175
|
+
// is the stamp the URL carries (`?v=`), `current` the file's stamp now. Only
|
|
176
|
+
// a URL whose stamp is current is kept for good: an older page asking for an
|
|
177
|
+
// address whose file has since changed gets the new bytes, which must not be
|
|
178
|
+
// kept under the old address. Everything else carries a tag of its bytes and
|
|
179
|
+
// is checked with the server each time, a round trip and no download when it
|
|
180
|
+
// has not changed. `private`: a maker live link serves only its owner.
|
|
181
|
+
export function cacheHeaders({ requested, current, body }) {
|
|
182
|
+
if (requested && current && requested === current) {
|
|
183
|
+
return { 'Cache-Control': 'private, max-age=31536000, immutable' };
|
|
184
|
+
}
|
|
185
|
+
return { 'Cache-Control': 'no-cache', ETag: `"${hashOf(body)}"` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Whether a request already holds these bytes (its If-None-Match names the
|
|
189
|
+
// tag, weak or strong, alone or in a list).
|
|
190
|
+
export function notModified(req, etag) {
|
|
191
|
+
const header = req.headers?.['if-none-match'];
|
|
192
|
+
if (!header || !etag) return false;
|
|
193
|
+
return header.split(',').some((t) => t.trim().replace(/^W\//, '') === etag);
|
|
194
|
+
}
|
|
195
|
+
export { declaredAssets, WARM_PAGE, WARM_PATH } from './warm.mjs';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The warm page the dev server serves at /__joystack/warm/<slug>/, which
|
|
2
|
+
// downloads a game's start ahead of it, and the declared assets its list
|
|
3
|
+
// carries beside the start's modules (startFiles). See README.md.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const WARM_PATH = '/__joystack/warm';
|
|
8
|
+
|
|
9
|
+
// The warm page (static-server.mjs, serveWarm): it downloads each file of
|
|
10
|
+
// its list, a few at a time and at low priority, into the cache the game's
|
|
11
|
+
// own page reads, and again whenever the server moves to a new version. A pass started while one
|
|
12
|
+
// runs waits for it. `data-warmed` on the document counts the files of the
|
|
13
|
+
// last finished pass.
|
|
14
|
+
export const WARM_PAGE = `<!doctype html><meta charset="utf-8"><title>warm</title><script>
|
|
15
|
+
(() => {
|
|
16
|
+
let running = false;
|
|
17
|
+
let again = false;
|
|
18
|
+
async function warm() {
|
|
19
|
+
if (running) { again = true; return; }
|
|
20
|
+
running = true;
|
|
21
|
+
try {
|
|
22
|
+
const { files = [] } = await (await fetch('files.json', { cache: 'no-store' })).json();
|
|
23
|
+
let i = 0;
|
|
24
|
+
const next = async () => {
|
|
25
|
+
while (i < files.length) {
|
|
26
|
+
const url = files[i++];
|
|
27
|
+
try { await (await fetch(url, { priority: 'low' })).arrayBuffer(); } catch {}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
await Promise.all([next(), next(), next(), next()]);
|
|
31
|
+
document.documentElement.dataset.warmed = String(files.length);
|
|
32
|
+
} catch {}
|
|
33
|
+
running = false;
|
|
34
|
+
if (again) { again = false; warm(); }
|
|
35
|
+
}
|
|
36
|
+
warm();
|
|
37
|
+
const es = new EventSource('/__reload?page=warm');
|
|
38
|
+
es.onmessage = (e) => { if (e.data === 'reload') warm(); };
|
|
39
|
+
})();
|
|
40
|
+
</script>`;
|
|
41
|
+
|
|
42
|
+
// The URLs of the files the game's assets.js declares that the server has,
|
|
43
|
+
// read by the engine's own reader of the declaration; none from an engine
|
|
44
|
+
// without one.
|
|
45
|
+
export async function declaredAssets({ slug, resolveUrl }) {
|
|
46
|
+
const decl = resolveUrl(`/games/${slug}/assets.js`);
|
|
47
|
+
const reader = resolveUrl('/shared/ui/assets/declared.js');
|
|
48
|
+
if (!decl || !reader) return [];
|
|
49
|
+
try {
|
|
50
|
+
const { readDeclaration } = await import(pathToFileURL(reader).href);
|
|
51
|
+
const base = `http://x/games/${slug}/`;
|
|
52
|
+
return [...readDeclaration(readFileSync(decl, 'utf8')).sources.values()]
|
|
53
|
+
.map((src) => { try { return new URL(src, base); } catch { return null; } })
|
|
54
|
+
.filter((u) => u && u.origin === 'http://x' && resolveUrl(u.pathname))
|
|
55
|
+
// Under the URL the game asks for, its query included.
|
|
56
|
+
.map((u) => u.pathname + u.search);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/inject.mjs
CHANGED
|
@@ -7,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
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// * ?v=<stamp> rewriting on <script src> and JS imports: each URL carries the
|
|
11
|
+
// stamp of its import closure's content (fingerprint/), so an edit renames
|
|
12
|
+
// it and an unchanged file keeps its address, and with it the browser's
|
|
13
|
+
// copy.
|
|
13
14
|
import { dirname } from 'node:path';
|
|
15
|
+
import { closureStamps, JS_IMPORT_RE, resolveSpecToUrl } from './fingerprint/fingerprint.mjs';
|
|
14
16
|
import { LEGACY_PAGE_GLOBALS, withLegacyDoors } from './legacy-names.mjs';
|
|
15
17
|
|
|
16
18
|
// The identity every `joystack dev` page is signed in as.
|
|
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
|
|
21
|
-
// (
|
|
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
|
|
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,
|
|
37
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
186
|
+
log(devTools ? `✅ Live (review build, dev tools on): ${url}` : `✅ Live: ${url}`);
|
|
187
187
|
return { slug: out.slug, url };
|
|
188
188
|
}
|
|
189
189
|
|
package/lib/replay-store.mjs
CHANGED
|
@@ -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
|
-
+
|
|
716
|
+
+ '(Keep one in Sessions, in the dev tools, to hold on to it)');
|
|
717
717
|
}
|
|
718
718
|
return result;
|
|
719
719
|
}
|