@joystack/cli 0.0.0-stage → 0.1.43
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 +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
package/lib/npm.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The one way the CLI shells out to npm.
|
|
2
|
+
//
|
|
3
|
+
// Why this module exists: on Windows npm is not a binary, it's an `npm.cmd`
|
|
4
|
+
// shim, and Node cannot launch a .bat/.cmd from execFile/spawn. A bare
|
|
5
|
+
// execFileSync('npm', …) dies with ENOENT — which is exactly how `joystack
|
|
6
|
+
// create` failed for our first Windows creator (2026-07-11).
|
|
7
|
+
//
|
|
8
|
+
// The obvious fix is a trap: renaming to 'npm.cmd' does NOT work either.
|
|
9
|
+
// Since the CVE-2024-27980 fix (Node >= 18.20.2 / 20.12.2 / 21.7.3) Node
|
|
10
|
+
// refuses to spawn a .bat/.cmd without `shell: true`, so the rename just
|
|
11
|
+
// trades ENOENT for EINVAL. And raw `shell: true` is its own trap: cmd.exe
|
|
12
|
+
// re-parses the argv we hand it, so an unquoted path with a space in it
|
|
13
|
+
// (ensureEngine passes an absolute tarball path, and Windows home dirs are
|
|
14
|
+
// routinely "C:\Users\John Smith\…") silently breaks.
|
|
15
|
+
//
|
|
16
|
+
// cross-spawn does the whole dance: detects the shim, routes it through
|
|
17
|
+
// cmd.exe, escapes the args. It's the standard fix and it's what npm's own
|
|
18
|
+
// tooling uses.
|
|
19
|
+
import spawn from 'cross-spawn';
|
|
20
|
+
|
|
21
|
+
// Callers treat a throw as "npm failed" (create aborts the scaffold,
|
|
22
|
+
// ensureEngine falls back). cross-spawn's sync API mirrors spawnSync — it
|
|
23
|
+
// RETURNS a non-zero status rather than throwing — so re-throw here, or a
|
|
24
|
+
// failed install would sail past every call site as success.
|
|
25
|
+
export function runNpm(args, opts = {}) {
|
|
26
|
+
const res = spawn.sync('npm', args, { stdio: 'pipe', ...opts });
|
|
27
|
+
if (res.error) throw res.error;
|
|
28
|
+
if (res.status !== 0) {
|
|
29
|
+
const err = new Error(
|
|
30
|
+
`npm ${args.join(' ')} failed (exit ${res.status})` +
|
|
31
|
+
(res.stderr?.length ? `\n${res.stderr.toString().trim()}` : ''),
|
|
32
|
+
);
|
|
33
|
+
err.status = res.status;
|
|
34
|
+
err.stderr = res.stderr;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
return res;
|
|
38
|
+
}
|
package/lib/ports.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Port conventions + takeover.
|
|
2
|
+
//
|
|
3
|
+
// Canonical ports: static :8000, partykit :1999. The engine's browser client
|
|
4
|
+
// derives the partykit port from location.port (1999 + (static − 8000)), so a
|
|
5
|
+
// non-default --port shifts both together and everything keeps lining up.
|
|
6
|
+
//
|
|
7
|
+
// Takeover: `joystack dev` must "just work" — a stale dev session or orphaned
|
|
8
|
+
// workerd holding a port means the user gets stale code with no recourse, so
|
|
9
|
+
// the listener is TERMed (then KILLed) instead of skipped.
|
|
10
|
+
//
|
|
11
|
+
// …but ONLY when it is our own. `portInUse()` is a bare TCP connect, so taking
|
|
12
|
+
// over whatever answers would kill another checkout's dev stack (a second copy
|
|
13
|
+
// of the same game, another game) and steal its ports. So a port has an OWNER,
|
|
14
|
+
// and the answer decides:
|
|
15
|
+
//
|
|
16
|
+
// free → take it
|
|
17
|
+
// our own dev server → take it OVER (the stale-code case above)
|
|
18
|
+
// another project's, or a
|
|
19
|
+
// foreign app's → LEAVE IT and step to the next block
|
|
20
|
+
//
|
|
21
|
+
// Ownership is asked, not inferred: the static server answers `/__joystack/whoami`
|
|
22
|
+
// with its project dir (static-server.mjs). No registry file to go stale, and
|
|
23
|
+
// nothing to clean up after a crash — if it answers, it's alive.
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import { networkInterfaces } from 'node:os';
|
|
27
|
+
|
|
28
|
+
export const STATIC_PORT_BASE = 8000;
|
|
29
|
+
export const MP_PORT_BASE = 1999;
|
|
30
|
+
|
|
31
|
+
// Ports a BROWSER refuses to connect to (ERR_UNSAFE_PORT), from Chromium's
|
|
32
|
+
// net/base/port_util.cc `kRestrictedPorts` (Firefox bans the same set). The
|
|
33
|
+
// mp port is derived from the static port, so a block landing either on one of
|
|
34
|
+
// these silently breaks the browser connection — while curl and a
|
|
35
|
+
// headless probe connect fine, so every server-side check looks healthy. The
|
|
36
|
+
// one that bites in the normal range is 2049 (NFS): static 8050 → mp 2049. The
|
|
37
|
+
// full set keeps it robust if the derivation range ever shifts. Reported by a
|
|
38
|
+
// creator (feedback fb_c690e35cff30511e).
|
|
39
|
+
export const RESTRICTED_PORTS = new Set([
|
|
40
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
|
|
41
|
+
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
|
|
42
|
+
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
|
|
43
|
+
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
|
|
44
|
+
2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697,
|
|
45
|
+
10080,
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export function isRestricted(port) {
|
|
49
|
+
return RESTRICTED_PORTS.has(port);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True if a static base derives a block where static or mp is browser-restricted. */
|
|
53
|
+
export function blockIsRestricted(staticPort) {
|
|
54
|
+
const p = portsFor(staticPort);
|
|
55
|
+
return isRestricted(p.static) || isRestricted(p.mp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The first static port ≥ `staticPort` whose whole derived block is browser-reachable. */
|
|
59
|
+
export function nextBrowserSafe(staticPort) {
|
|
60
|
+
let p = staticPort;
|
|
61
|
+
while (blockIsRestricted(p)) p += 1;
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Ports move in BLOCKS: the browser derives mp from location.port, so the
|
|
66
|
+
// whole stack shifts together. 8000/1999 → 8010/2009 → …
|
|
67
|
+
export const BLOCK_STRIDE = 10;
|
|
68
|
+
export const WHOAMI_PATH = '/__joystack/whoami';
|
|
69
|
+
|
|
70
|
+
export function portsFor(staticPort = STATIC_PORT_BASE) {
|
|
71
|
+
const offset = staticPort - STATIC_PORT_BASE;
|
|
72
|
+
return { static: staticPort, mp: MP_PORT_BASE + offset };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Who is on this port? → { projectDir, pid } if one of our dev servers answers,
|
|
76
|
+
// null if the port is silent, or if something that isn't us holds it (a foreign
|
|
77
|
+
// app owes us no answer — and either way the verdict is "not mine, don't touch").
|
|
78
|
+
export function whoIsOn(port, timeoutMs = 400) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const req = net.connect({ port, host: '127.0.0.1' });
|
|
81
|
+
let body = '';
|
|
82
|
+
const done = (v) => {
|
|
83
|
+
req.destroy();
|
|
84
|
+
resolve(v);
|
|
85
|
+
};
|
|
86
|
+
req.setTimeout(timeoutMs, () => done(null));
|
|
87
|
+
req.on('error', () => done(null)); // nothing listening
|
|
88
|
+
req.on('connect', () => {
|
|
89
|
+
req.write(`GET ${WHOAMI_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n`);
|
|
90
|
+
});
|
|
91
|
+
req.on('data', (chunk) => {
|
|
92
|
+
body += chunk;
|
|
93
|
+
const i = body.indexOf('\r\n\r\n');
|
|
94
|
+
if (i === -1) return; // headers still arriving
|
|
95
|
+
if (!/^HTTP\/1\.[01] 200/.test(body)) return done(null); // answered, but not ours
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(body.slice(i + 4));
|
|
98
|
+
done(parsed.projectDir ? parsed : null);
|
|
99
|
+
} catch {
|
|
100
|
+
/* body may still be arriving; wait for more, or for the timeout */
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
req.on('end', () => done(null));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pick the port block this dev stack should bind.
|
|
108
|
+
//
|
|
109
|
+
// `requested` (an explicit --port) is obeyed as given, takeover included: you
|
|
110
|
+
// named the port, you get the port. Explicit beats clever — it's the AUTO path
|
|
111
|
+
// that has to be safe, because that's the one nobody thought about.
|
|
112
|
+
// `base` exists so the tests can run in a private port range: a suite that
|
|
113
|
+
// probes :8000 for real would fight (and report on) whatever dev servers the
|
|
114
|
+
// developer has running — which is how this file's own tests first "failed".
|
|
115
|
+
export async function resolvePorts({
|
|
116
|
+
projectDir,
|
|
117
|
+
requested = null,
|
|
118
|
+
blocks = 24,
|
|
119
|
+
base = STATIC_PORT_BASE,
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (requested != null) {
|
|
122
|
+
return { ports: portsFor(requested), tookOver: true, steppedAside: [] };
|
|
123
|
+
}
|
|
124
|
+
const steppedAside = [];
|
|
125
|
+
for (let i = 0; i < blocks; i++) {
|
|
126
|
+
const ports = portsFor(base + i * BLOCK_STRIDE);
|
|
127
|
+
// A block whose static or mp port lands on a browser-restricted port is
|
|
128
|
+
// unusable no matter how free it is — the browser refuses the connection
|
|
129
|
+
// (ERR_UNSAFE_PORT) even though curl and server checks pass. Skip it before
|
|
130
|
+
// probing, so auto-allocation only ever hands the creator a browser-
|
|
131
|
+
// reachable stack. (feedback fb_c690e35cff30511e — mp 2049/NFS on base 8050.)
|
|
132
|
+
if (blockIsRestricted(base + i * BLOCK_STRIDE)) {
|
|
133
|
+
steppedAside.push({ port: ports.static, projectDir: null, reason: 'browser-restricted' });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
|
|
137
|
+
|
|
138
|
+
if (owner === undefined) {
|
|
139
|
+
// The game port is free — but a block only isolates if the WHOLE stack
|
|
140
|
+
// fits in it. Half a stack here and half somewhere else is worse than
|
|
141
|
+
// moving on, so a contaminated block is skipped entire.
|
|
142
|
+
if (await portInUse(ports.mp)) {
|
|
143
|
+
steppedAside.push({ port: ports.static, projectDir: null });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
return { ports, tookOver: false, steppedAside };
|
|
147
|
+
}
|
|
148
|
+
if (owner && owner.projectDir === projectDir) {
|
|
149
|
+
return { ports, tookOver: true, steppedAside }; // our own stale server
|
|
150
|
+
}
|
|
151
|
+
steppedAside.push({ port: ports.static, projectDir: owner?.projectDir ?? null });
|
|
152
|
+
}
|
|
153
|
+
throw new Error(
|
|
154
|
+
`No free port block after trying ${blocks}. Close a dev server, or pass --port explicitly.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function portInUse(port) {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const sock = net.connect({ port, host: '127.0.0.1' });
|
|
161
|
+
const done = (v) => {
|
|
162
|
+
sock.destroy();
|
|
163
|
+
resolve(v);
|
|
164
|
+
};
|
|
165
|
+
sock.setTimeout(300, () => done(false));
|
|
166
|
+
sock.on('connect', () => done(true));
|
|
167
|
+
sock.on('error', () => done(false));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findPids(port) {
|
|
172
|
+
const pids = new Set();
|
|
173
|
+
try {
|
|
174
|
+
if (process.platform === 'win32') {
|
|
175
|
+
const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000 });
|
|
176
|
+
const re = new RegExp(String.raw`\s\S+:${port}\s+\S+\s+LISTENING\s+(\d+)`, 'i');
|
|
177
|
+
for (const line of out.split('\n')) {
|
|
178
|
+
const m = re.exec(line);
|
|
179
|
+
if (m) pids.add(Number(m[1]));
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], {
|
|
183
|
+
encoding: 'utf8',
|
|
184
|
+
timeout: 5000,
|
|
185
|
+
});
|
|
186
|
+
for (const line of out.split('\n')) if (/^\d+$/.test(line.trim())) pids.add(Number(line.trim()));
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// lsof exits 1 when nothing listens — that's "no pids".
|
|
190
|
+
}
|
|
191
|
+
return pids;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function killPid(pid, force) {
|
|
195
|
+
try {
|
|
196
|
+
if (process.platform === 'win32') {
|
|
197
|
+
execFileSync('taskkill', [...(force ? ['/F'] : []), '/PID', String(pid)], { timeout: 5000 });
|
|
198
|
+
} else {
|
|
199
|
+
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// Already gone.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
207
|
+
|
|
208
|
+
export async function killPort(port) {
|
|
209
|
+
let pids = findPids(port);
|
|
210
|
+
if (!pids.size) return true;
|
|
211
|
+
for (const p of pids) killPid(p, false);
|
|
212
|
+
for (let i = 0; i < 10; i++) {
|
|
213
|
+
await sleep(200);
|
|
214
|
+
if (!findPids(port).size) return true;
|
|
215
|
+
}
|
|
216
|
+
for (const p of findPids(port)) killPid(p, true);
|
|
217
|
+
for (let i = 0; i < 10; i++) {
|
|
218
|
+
await sleep(200);
|
|
219
|
+
if (!findPids(port).size) return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function lanIp() {
|
|
225
|
+
// First non-internal private IPv4 — the address a phone on the same wifi
|
|
226
|
+
// uses to reach this machine. Null when offline / no private interface.
|
|
227
|
+
for (const addrs of Object.values(networkInterfaces())) {
|
|
228
|
+
for (const a of addrs ?? []) {
|
|
229
|
+
if (a.family !== 'IPv4' || a.internal) continue;
|
|
230
|
+
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address)) return a.address;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// The game's own server primitives — discovery, and the registry the CLI
|
|
2
|
+
// generates from them.
|
|
3
|
+
//
|
|
4
|
+
// THE ONE RULE
|
|
5
|
+
//
|
|
6
|
+
// A file at `overrides/shared/<engine path>` IS that engine file, for this
|
|
7
|
+
// game. And `shared/ui/room/primitives/**` is the only part of it the SERVER
|
|
8
|
+
// runs; everything else under `overrides/shared/` is browser-only.
|
|
9
|
+
//
|
|
10
|
+
// One mechanism, one folder and one mental model serve both the browser and the
|
|
11
|
+
// server — there is no separate server-only folder. Overriding and adding are
|
|
12
|
+
// one action too: the engine has a `player-body.js` and does not have a
|
|
13
|
+
// `gravity-well.js`, so both are the same act — put a file at the path. The creator never has to
|
|
14
|
+
// know which one they're doing.
|
|
15
|
+
//
|
|
16
|
+
// FILENAME IS THE TYPE NAME. `gravity-well.js` → `{ type: 'gravity-well' }` in a
|
|
17
|
+
// world spec. A folder with an `index.js` works too. There is no barrel to
|
|
18
|
+
// maintain and no `extraPrimitives` argument to remember: the registry is
|
|
19
|
+
// generated from the directory, for both sides, from this one scan, so the
|
|
20
|
+
// client predicts with the same class the server runs. (A client predicting
|
|
21
|
+
// with the engine's class while the server ran the creator's would disagree
|
|
22
|
+
// every tick and rubber-band.)
|
|
23
|
+
//
|
|
24
|
+
// `index.js` in the primitives dir is RESERVED — it IS the generated registry.
|
|
25
|
+
// A hand-written one would shadow the engine's and silently unregister every
|
|
26
|
+
// engine primitive, so it is rejected loudly instead.
|
|
27
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { gameConfig } from './legacy-names.mjs';
|
|
30
|
+
|
|
31
|
+
// The one path that is server code. Mirrors the engine's own layout, because
|
|
32
|
+
// that is the whole point — this is `shared/ui/room/primitives/`, for this game.
|
|
33
|
+
export const PRIMITIVES_DIR = 'overrides/shared/ui/room/primitives';
|
|
34
|
+
|
|
35
|
+
// The two files the CLI GENERATES for a game with primitives. Neither is ever
|
|
36
|
+
// hand-written, neither is in the creator's git.
|
|
37
|
+
//
|
|
38
|
+
// SERVER_BARREL — the `extraPrimitives` map the room's GameRoom subclass
|
|
39
|
+
// hands to the host. Bundled into the per-game Worker at
|
|
40
|
+
// publish; esbuilt into the dev room server by `joystack dev`.
|
|
41
|
+
// It also IS the publish endpoint's server-backed detector.
|
|
42
|
+
//
|
|
43
|
+
// BROWSER_REGISTRY — a shadow of the engine's primitive registry, merged:
|
|
44
|
+
// `{ ...engine, ...the game's }`. It is what lets the CLIENT
|
|
45
|
+
// predict with the game's own classes without a
|
|
46
|
+
// `createRoom({ extraPrimitives })` argument. It shadows
|
|
47
|
+
// index.js, which is a one-line re-export of engine.js
|
|
48
|
+
// precisely so this merge is possible (a shadow cannot import
|
|
49
|
+
// the file it replaces).
|
|
50
|
+
export const SERVER_BARREL = '_joystack/primitives.js';
|
|
51
|
+
export const BROWSER_REGISTRY = `${PRIMITIVES_DIR}/index.js`;
|
|
52
|
+
|
|
53
|
+
// A framework-v2 game's publish upload carries its lowered room MODULE here (in
|
|
54
|
+
// place of a primitives barrel): the CLI-bundled framework runtime that computes
|
|
55
|
+
// the v1 trio at load and exports it as `frameworkV2Bundle`. Its presence is
|
|
56
|
+
// what makes the publish endpoint pick the v2 worker entry. MUST match
|
|
57
|
+
// ROOM_MODULE_PATH in backend/api/src/lib/game-server.js.
|
|
58
|
+
export const SERVER_V2_ROOM = '_joystack/v2-room.js';
|
|
59
|
+
|
|
60
|
+
// Emitted by the engine build (packages/engine/build.mjs): the DERIVED set of
|
|
61
|
+
// `/shared/...` modules a game may NOT override — the room-runtime bundle closure
|
|
62
|
+
// minus catalogued primitives minus the browser-registry seam. The build does the
|
|
63
|
+
// derivation once; this guard and the publish endpoint (resolvePublish) both read
|
|
64
|
+
// the same file, so there is no second copy to drift.
|
|
65
|
+
export const SERVER_RESERVED_MANIFEST = 'room-server/server-reserved.json';
|
|
66
|
+
|
|
67
|
+
// A primitive's source is `<type>.js` or `<type>/index.js`. Anything else next
|
|
68
|
+
// to it — tests, a README, a helper module a primitive imports — is not one.
|
|
69
|
+
const IS_TEST = /\.(test|smoke)\.(js|mjs)$/;
|
|
70
|
+
|
|
71
|
+
// `export default class Foo {}` / `export default Foo` / `export { Foo as default }`.
|
|
72
|
+
const HAS_DEFAULT_EXPORT = /^\s*export\s+default\b|^\s*export\s*\{[^}]*\bas\s+default\b/m;
|
|
73
|
+
|
|
74
|
+
function isPrimitiveFile(name) {
|
|
75
|
+
return name.endsWith('.js') && !IS_TEST.test(name) && name !== 'index.js';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// → [{ type, module }], sorted by type. `module` is the game-root-relative path
|
|
79
|
+
// to the class file, always POSIX-separated (it goes into generated source).
|
|
80
|
+
//
|
|
81
|
+
// Sorted because the output is bundled and content-hashed at publish, and a
|
|
82
|
+
// release is immutable: an unstable order would mean an unstable hash for
|
|
83
|
+
// bytes that never changed.
|
|
84
|
+
export function scanPrimitives(projectDir) {
|
|
85
|
+
const root = join(projectDir, PRIMITIVES_DIR);
|
|
86
|
+
if (!existsSync(root)) return [];
|
|
87
|
+
|
|
88
|
+
const found = [];
|
|
89
|
+
for (const name of readdirSync(root)) {
|
|
90
|
+
const abs = join(root, name);
|
|
91
|
+
|
|
92
|
+
if (statSync(abs).isDirectory()) {
|
|
93
|
+
if (existsSync(join(abs, 'index.js'))) {
|
|
94
|
+
found.push({ type: name, module: `${PRIMITIVES_DIR}/${name}/index.js` });
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (name === 'index.js') {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${PRIMITIVES_DIR}/index.js is reserved — it is the primitive registry, and Joystack generates it for you.\n` +
|
|
102
|
+
'Delete it. Every .js file in that folder is registered automatically, using its filename as the type name\n' +
|
|
103
|
+
"(so `gravity-well.js` is `{ type: 'gravity-well' }` in your world spec).",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isPrimitiveFile(name)) {
|
|
108
|
+
// Every top-level .js here IS a primitive — the filename is the type name,
|
|
109
|
+
// so there is nowhere for a plain helper module to hide. Catch it now, in
|
|
110
|
+
// the creator's own words, rather than as an esbuild error about a missing
|
|
111
|
+
// default export in a barrel they never wrote and cannot see.
|
|
112
|
+
if (!HAS_DEFAULT_EXPORT.test(readFileSync(abs, 'utf8'))) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${PRIMITIVES_DIR}/${name} has no default export, so it cannot be a primitive.\n` +
|
|
115
|
+
'A primitive is `export default class { ... }`, and its filename is its type name.\n' +
|
|
116
|
+
`If ${name} is a HELPER your primitive imports, put it in a lib/ subfolder here —\n` +
|
|
117
|
+
` ${PRIMITIVES_DIR}/lib/${name}\n` +
|
|
118
|
+
`and import it as './lib/${name}'. The scanner skips lib/, and a primitive runs on BOTH\n` +
|
|
119
|
+
'sides — bundled into the room AND served to the browser to predict with — so its helper\n' +
|
|
120
|
+
'has to sit under this /shared/ tree to be reachable from both. Anywhere else in your game\n' +
|
|
121
|
+
'resolves for the room but 404s in the browser.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
found.push({ type: name.slice(0, -3), module: `${PRIMITIVES_DIR}/${name}` });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
found.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A framework-v2 game (package.json `joystack.framework === 'v2'`) authors
|
|
133
|
+
// entities + traits + a world.js and lowers to v1 artifacts at build time.
|
|
134
|
+
// It always runs its own (lowered) code on the server, so it is server-backed
|
|
135
|
+
// by definition — even with no overrides/ or components/ folder. The room
|
|
136
|
+
// server for it is built by the framework compiler (buildV2RoomServer), not
|
|
137
|
+
// the primitive scanner.
|
|
138
|
+
export function isFrameworkV2(projectDir) {
|
|
139
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
140
|
+
if (!existsSync(pkgPath)) return false;
|
|
141
|
+
try {
|
|
142
|
+
return gameConfig(JSON.parse(readFileSync(pkgPath, 'utf8'))).framework === 'v2';
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Does this game run its own code on the server? The ONE predicate — `dev` and
|
|
149
|
+
// `publish` both ask it, so a game can never be server-backed in one and not the
|
|
150
|
+
// other. (If they asked different questions, a creator's primitive could run in
|
|
151
|
+
// production and silently not run in dev.)
|
|
152
|
+
//
|
|
153
|
+
// TWO trees make a game server-backed: vendored primitives (overrides/) and
|
|
154
|
+
// entity components (components/, or entity-local ones under entities/). The
|
|
155
|
+
// entity half is a cheap folder check, not a scan — a malformed component
|
|
156
|
+
// still makes the game server-backed; the build is where it fails loudly.
|
|
157
|
+
export function isServerBacked(projectDir) {
|
|
158
|
+
if (isFrameworkV2(projectDir)) return true;
|
|
159
|
+
if (scanPrimitives(projectDir).length > 0) return true;
|
|
160
|
+
if (existsSync(join(projectDir, 'components'))) return true;
|
|
161
|
+
const entitiesDir = join(projectDir, 'entities');
|
|
162
|
+
if (!existsSync(entitiesDir)) return false;
|
|
163
|
+
return readdirSync(entitiesDir).some((name) => {
|
|
164
|
+
try { return existsSync(join(entitiesDir, name, 'components')); } catch { return false; }
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// VQ7M2K: a framework-v2 game is SINGLE-PLAYER unless it declares
|
|
169
|
+
// config({ multiplayer: true }). A single-player game runs its authoritative sim
|
|
170
|
+
// in-process (the LocalRoom transport) with no wire — so dev stands up no partykit
|
|
171
|
+
// room and publish deploys no per-game room worker. The multiplayer flag lives in
|
|
172
|
+
// the built skeleton's config (emitSkeleton → graph.config, default false); a game
|
|
173
|
+
// with no skeleton (a v1 primitive/entity game) is never single-player in this
|
|
174
|
+
// sense and keeps its room. Callers pass the skeleton they already built.
|
|
175
|
+
export function isSinglePlayerV2({ projectDir, skeleton }) {
|
|
176
|
+
return isFrameworkV2(projectDir) && skeleton?.config?.multiplayer !== true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The one cycle shadowing makes possible.
|
|
180
|
+
//
|
|
181
|
+
// A NEW primitive may subclass an engine one — that is the RECOMMENDED way to
|
|
182
|
+
// change a primitive's behaviour, because you keep taking engine updates to the
|
|
183
|
+
// base and write only what differs:
|
|
184
|
+
//
|
|
185
|
+
// // overrides/shared/ui/room/primitives/terrain-walk.js ← a NEW name
|
|
186
|
+
// import { PlayerBody } from './engine.js';
|
|
187
|
+
// export default class TerrainWalk extends PlayerBody { … }
|
|
188
|
+
//
|
|
189
|
+
// A file that OVERRIDES `player-body` cannot do the same, and the reason is not
|
|
190
|
+
// obvious: `engine.js` imports `./player-body.js`, and that specifier now resolves
|
|
191
|
+
// to the game's own file. engine.js → your file → engine.js. In the browser that
|
|
192
|
+
// surfaces as "Cannot access 'PlayerBody' before initialization" at boot, which
|
|
193
|
+
// tells the creator nothing about what they actually did wrong.
|
|
194
|
+
//
|
|
195
|
+
// So: overriding a primitive in place is a FORK — self-contained, no engine.js.
|
|
196
|
+
// Want to subclass? Give it a new name. That is what this error says.
|
|
197
|
+
export function assertNoOverrideCycle(primitives, projectDir, sharedDir) {
|
|
198
|
+
const engineDir = join(sharedDir, 'ui', 'room', 'primitives');
|
|
199
|
+
for (const p of primitives) {
|
|
200
|
+
const shadowsEngine =
|
|
201
|
+
existsSync(join(engineDir, `${p.type}.js`)) || existsSync(join(engineDir, p.type, 'index.js'));
|
|
202
|
+
if (!shadowsEngine) continue;
|
|
203
|
+
if (!IMPORTS_ENGINE_JS.test(readFileSync(join(projectDir, p.module), 'utf8'))) continue;
|
|
204
|
+
|
|
205
|
+
throw new Error(
|
|
206
|
+
`${p.module} overrides the engine's '${p.type}' primitive AND imports './engine.js' — that is a cycle.\n` +
|
|
207
|
+
`engine.js imports './${p.type}.js', which is now YOUR file, which imports engine.js.\n` +
|
|
208
|
+
'\n' +
|
|
209
|
+
'Two ways out:\n' +
|
|
210
|
+
` • Subclass instead of overriding — rename your file (e.g. my-${p.type}.js), keep the\n` +
|
|
211
|
+
` './engine.js' import, and use { type: 'my-${p.type}' } in your world spec. You keep\n` +
|
|
212
|
+
' taking engine updates to the base class. This is usually what you want.\n' +
|
|
213
|
+
` • Or keep the name and make it a FORK — drop the './engine.js' import and write the\n` +
|
|
214
|
+
` primitive self-contained. It stops receiving engine updates, like any override.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A real `import … from './engine.js'` STATEMENT — line-anchored, and not inside
|
|
220
|
+
// a comment.
|
|
221
|
+
//
|
|
222
|
+
// A bare /from ['"]\.\/engine\.js['"]/ would match the sentence "…import the base
|
|
223
|
+
// from './engine.js'" in a code COMMENT and refuse to build a primitive that is
|
|
224
|
+
// doing nothing wrong. A guard that fires on prose is worse than no guard: it
|
|
225
|
+
// cries wolf, and the next person turns it off.
|
|
226
|
+
const IMPORTS_ENGINE_JS = /^(?!\s*(?:\/\/|\*|\/\*))\s*import\b[^\n]*?\bfrom\s*['"]\.\/engine\.js['"]/m;
|
|
227
|
+
|
|
228
|
+
// The generated registry, as source. Imported by the dev room server and bundled
|
|
229
|
+
// into the per-game worker at publish; both feed it to `GameRoom.extraPrimitives()`.
|
|
230
|
+
//
|
|
231
|
+
// Paths are relative to the GAME ROOT — the barrel is written to `.joystack/` but
|
|
232
|
+
// esbuild is given the game root as its resolve base, so `./overrides/...`
|
|
233
|
+
// resolves the same in dev and at publish.
|
|
234
|
+
export function renderBarrel(primitives) {
|
|
235
|
+
const lines = [
|
|
236
|
+
'// GENERATED by the Joystack CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
|
|
237
|
+
'//',
|
|
238
|
+
'// Every .js file in that folder is a server primitive, registered under its',
|
|
239
|
+
'// filename. Add one by adding a file; there is nothing else to wire up.',
|
|
240
|
+
'',
|
|
241
|
+
];
|
|
242
|
+
primitives.forEach((p, i) => {
|
|
243
|
+
lines.push(`import P${i} from './${p.module}';`);
|
|
244
|
+
});
|
|
245
|
+
lines.push('');
|
|
246
|
+
lines.push('export const extraPrimitives = {');
|
|
247
|
+
primitives.forEach((p, i) => {
|
|
248
|
+
lines.push(` '${p.type}': P${i},`);
|
|
249
|
+
});
|
|
250
|
+
lines.push('};');
|
|
251
|
+
lines.push('');
|
|
252
|
+
return lines.join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The BROWSER half: a shadow of the engine's primitive registry, merged.
|
|
256
|
+
//
|
|
257
|
+
// This is what lets the CLIENT predict with the game's own classes. Without it,
|
|
258
|
+
// the server runs the creator's primitive while the client predicts with the
|
|
259
|
+
// ENGINE's class of the same name — they disagree every tick and the game
|
|
260
|
+
// rubber-bands. The creator never has to wire this up (no
|
|
261
|
+
// `createRoom({ extraPrimitives })`), because the platform generates it.
|
|
262
|
+
//
|
|
263
|
+
// It is written to `overrides/shared/ui/room/primitives/index.js`, which shadows
|
|
264
|
+
// the engine's `index.js` — a deliberate one-line re-export of `engine.js` that
|
|
265
|
+
// exists ONLY so this merge is possible. A shadow cannot import the file it
|
|
266
|
+
// replaces (that's a cycle), so it reaches the engine's list through `engine.js`,
|
|
267
|
+
// which nothing shadows.
|
|
268
|
+
//
|
|
269
|
+
// The served path this registry (and the engine's) lives at — `PRIMITIVES_DIR`
|
|
270
|
+
// with the game-repo `overrides/` prefix stripped. Imports below are keyed to
|
|
271
|
+
// THIS, absolutely.
|
|
272
|
+
const SERVED_PRIMITIVES_DIR = `/${PRIMITIVES_DIR.replace(/^overrides\//, '')}`;
|
|
273
|
+
|
|
274
|
+
// Every import here is ABSOLUTE `/shared/...`, NOT relative — and that is
|
|
275
|
+
// load-bearing. This file is a shared module served content-addressed: in
|
|
276
|
+
// production it lands at `/_shared/<hash>/shared/ui/room/primitives/index.js`
|
|
277
|
+
// and `/shared/...` specifiers resolve through the game's import map. A RELATIVE
|
|
278
|
+
// `./engine.js` would instead resolve against that hashed URL — same hash prefix
|
|
279
|
+
// → the SAME (index.js) bytes come back, whose `default` export doesn't exist →
|
|
280
|
+
// the client predictor never loads and the game boots broken in prod while
|
|
281
|
+
// working in dev (where `/shared/` is served live and relative happens to work).
|
|
282
|
+
// So the generated file is born absolute, like every other shared module.
|
|
283
|
+
// '/shared/ui/room/primitives/engine.js' → not shadowed → the engine's list
|
|
284
|
+
// '/shared/ui/room/primitives/beacon.js' → shadowed → the game's primitive
|
|
285
|
+
export function renderBrowserRegistry(primitives) {
|
|
286
|
+
const lines = [
|
|
287
|
+
'// GENERATED by the Joystack CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
|
|
288
|
+
'//',
|
|
289
|
+
"// This shadows the engine's primitive registry and merges your primitives",
|
|
290
|
+
'// into it, so the client predicts your movement with YOUR code — the same',
|
|
291
|
+
'// code the room is running. There is nothing to wire up in your game.',
|
|
292
|
+
'',
|
|
293
|
+
`import { PRIMITIVES as ENGINE } from '${SERVED_PRIMITIVES_DIR}/engine.js';`,
|
|
294
|
+
];
|
|
295
|
+
primitives.forEach((p, i) => {
|
|
296
|
+
// Sibling paths INSIDE the shadowed folder — strip the repo-relative prefix.
|
|
297
|
+
const rel = p.module.slice(PRIMITIVES_DIR.length + 1);
|
|
298
|
+
lines.push(`import P${i} from '${SERVED_PRIMITIVES_DIR}/${rel}';`);
|
|
299
|
+
});
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('export const PRIMITIVES = {');
|
|
302
|
+
// ...ENGINE FIRST: a game primitive sharing an engine primitive's name must
|
|
303
|
+
// WIN. Reverse these and an override would be silently clobbered by the engine.
|
|
304
|
+
lines.push(' ...ENGINE,');
|
|
305
|
+
primitives.forEach((p, i) => {
|
|
306
|
+
lines.push(` '${p.type}': P${i},`);
|
|
307
|
+
});
|
|
308
|
+
lines.push('};');
|
|
309
|
+
lines.push('');
|
|
310
|
+
return lines.join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── The silent no-op, made loud ──────────────────────────────────────────────
|
|
314
|
+
//
|
|
315
|
+
// Shadowing is a BROWSER mechanism: the dev server and the published import map
|
|
316
|
+
// serve the game's copy of `/shared/<path>` instead of the engine's. The room
|
|
317
|
+
// SERVER never resolves `/shared/...` at all — it runs `rooms-runtime.js`, which
|
|
318
|
+
// esbuild bundled at engine-build time, long before this game existed.
|
|
319
|
+
//
|
|
320
|
+
// So shadowing a file that is inside that bundle gets you a SPLIT BRAIN: your
|
|
321
|
+
// browser runs your copy, the room keeps running the engine's, the two disagree
|
|
322
|
+
// every tick, and nothing anywhere says why. Before this guard the entire class
|
|
323
|
+
// was accepted in silence — the file uploaded, served, and simply never ran on
|
|
324
|
+
// the server.
|
|
325
|
+
//
|
|
326
|
+
// The one exception is a primitive CLASS. That is the supported case, and the
|
|
327
|
+
// reason it works is that the CLI does NOT rely on shadowing to get it there: it
|
|
328
|
+
// bundles the class into the game's own room server and injects it through
|
|
329
|
+
// `extraPrimitives`, so both sides run the same code.
|
|
330
|
+
//
|
|
331
|
+
// WHY THE LIST IS DERIVED, NOT WRITTEN
|
|
332
|
+
//
|
|
333
|
+
// The closure is deep and it moves: server.js → runtime.js → host-core.js →
|
|
334
|
+
// tags.js, body-integration.js, wallet-credit.js, room-identity.js,
|
|
335
|
+
// cannon-physics.js … and `/shared/ui/jump/jump.js`, which nobody would ever
|
|
336
|
+
// have guessed belongs on a hand-written list. A list that is 90% right is worse
|
|
337
|
+
// than none: the 10% it misses is a guard that silently stopped guarding. So the
|
|
338
|
+
// engine build emits the exact set (metafile closure minus catalogued primitives
|
|
339
|
+
// minus the browser-registry seam) and we read it — the same file the publish
|
|
340
|
+
// endpoint reads, so the CLI's answer and the server's cannot diverge.
|
|
341
|
+
function reservedModules(engine) {
|
|
342
|
+
const manifest = join(engine.dir, SERVER_RESERVED_MANIFEST);
|
|
343
|
+
// A game pinned to an engine released before this guard existed has no
|
|
344
|
+
// manifest. That engine also has no override support worth guarding, so skip
|
|
345
|
+
// the check rather than refusing to build.
|
|
346
|
+
if (!existsSync(manifest)) return null;
|
|
347
|
+
try {
|
|
348
|
+
return new Set(JSON.parse(readFileSync(manifest, 'utf8')));
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Walk the game's authored overrides — `overrides/shared/**` — yielding the
|
|
355
|
+
// engine path each one claims to be (`/shared/ui/room/host-core.js`).
|
|
356
|
+
function* authoredOverrides(projectDir) {
|
|
357
|
+
const root = join(projectDir, 'overrides', 'shared');
|
|
358
|
+
if (!existsSync(root)) return;
|
|
359
|
+
const walk = function* (dir, rel) {
|
|
360
|
+
for (const name of readdirSync(dir)) {
|
|
361
|
+
const abs = join(dir, name);
|
|
362
|
+
const next = rel ? `${rel}/${name}` : name;
|
|
363
|
+
if (statSync(abs).isDirectory()) yield* walk(abs, next);
|
|
364
|
+
else if (name.endsWith('.js') && !IS_TEST.test(name)) yield { abs, shared: `/shared/${next}` };
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
yield* walk(root, '');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function assertNoInertOverride({ projectDir, engine }) {
|
|
371
|
+
const reserved = reservedModules(engine);
|
|
372
|
+
if (!reserved) return;
|
|
373
|
+
|
|
374
|
+
for (const { shared } of authoredOverrides(projectDir)) {
|
|
375
|
+
if (!reserved.has(shared)) continue; // overridable (primitive or browser-only)
|
|
376
|
+
|
|
377
|
+
const rel = shared.replace('/shared/', '');
|
|
378
|
+
throw new Error(
|
|
379
|
+
`overrides/shared/${rel} cannot be overridden — the room server runs this file, and it CANNOT take your copy.\n` +
|
|
380
|
+
'\n' +
|
|
381
|
+
'Your override would reach the browser but not the room: the server runs a runtime that was\n' +
|
|
382
|
+
'bundled before your game existed. Your client and the room would then be running different\n' +
|
|
383
|
+
'code, disagreeing every tick, with nothing to tell you why. So this is an error, not a warning.\n' +
|
|
384
|
+
'\n' +
|
|
385
|
+
'What you CAN override on the server is a primitive — a file in\n' +
|
|
386
|
+
` ${PRIMITIVES_DIR}/<name>.js\n` +
|
|
387
|
+
'Those are bundled into your game\'s own room server, so they run on BOTH sides.\n' +
|
|
388
|
+
'\n' +
|
|
389
|
+
'If you need behaviour this file has, put it in a primitive and use that instead.\n' +
|
|
390
|
+
'If you think the engine itself should change here, send it with `joystack feedback`.',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|