@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/dev.mjs
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// `joystack dev` — the standalone two-service dev stack:
|
|
2
|
+
//
|
|
3
|
+
// static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
|
|
4
|
+
// head injection + auto-reload
|
|
5
|
+
// mp :1999 local partykit running the BUNDLE's room-server code
|
|
6
|
+
//
|
|
7
|
+
// Multiplayer runs from minute one — the platform is not optional (the Next
|
|
8
|
+
// analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { terminateChildTree } from './process-tree.mjs';
|
|
11
|
+
import { withoutAgentSession } from './agent-session.mjs';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join, dirname, basename } from 'node:path';
|
|
15
|
+
import { findProject } from './project.mjs';
|
|
16
|
+
import { ensureEngine } from './engine.mjs';
|
|
17
|
+
import { createStaticServer } from './static-server.mjs';
|
|
18
|
+
import { pruneOnStart } from './replay-store.mjs';
|
|
19
|
+
import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
|
|
20
|
+
import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, scanPrimitives } from './primitives.mjs';
|
|
21
|
+
import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
|
|
22
|
+
import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
|
|
23
|
+
import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
|
|
24
|
+
import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
|
|
25
|
+
import { freezeGame } from './freeze.mjs';
|
|
26
|
+
import { readEnv, stateDir } from './legacy-names.mjs';
|
|
27
|
+
|
|
28
|
+
// Resolve the partykit CLI entry from OUR dependencies (the game never
|
|
29
|
+
// declares partykit; it rides @joystack/cli). partykit's `exports` map hides
|
|
30
|
+
// package.json from require.resolve, so walk node_modules dirs by hand.
|
|
31
|
+
export function partykitBin() {
|
|
32
|
+
let dir = import.meta.dirname;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const candidate = join(dir, 'node_modules', 'partykit', 'package.json');
|
|
35
|
+
if (existsSync(candidate)) {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
37
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.partykit;
|
|
38
|
+
return join(dirname(candidate), bin);
|
|
39
|
+
}
|
|
40
|
+
const parent = dirname(dir);
|
|
41
|
+
if (parent === dir) throw new Error('partykit is not installed alongside @joystack/cli — reinstall the CLI.');
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolves 'up' once the local room server answers an HTTP request, 'exited'
|
|
47
|
+
// as soon as `alive()` says its process ended, and 'timeout' if it has not
|
|
48
|
+
// answered within `timeoutMs`.
|
|
49
|
+
// Any HTTP status counts: partykit answers `GET /` with a 404 as soon as it is
|
|
50
|
+
// serving. An open port is not enough, because the port can accept a
|
|
51
|
+
// connection and then drop it while the server finishes starting.
|
|
52
|
+
export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs = 50, alive = () => true, fetchFn = fetch } = {}) {
|
|
53
|
+
const deadline = Date.now() + timeoutMs;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
if (!alive()) return 'exited';
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetchFn(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) });
|
|
58
|
+
// Releases the connection now instead of when the response is collected.
|
|
59
|
+
await res?.body?.cancel();
|
|
60
|
+
return 'up';
|
|
61
|
+
} catch {
|
|
62
|
+
/* not serving yet */
|
|
63
|
+
}
|
|
64
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
65
|
+
}
|
|
66
|
+
return alive() ? 'timeout' : 'exited';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Per-game server parity: a game with primitives of its own
|
|
70
|
+
// (overrides/shared/ui/room/primitives/) runs its OWN room server in dev, built
|
|
71
|
+
// from those classes. Without them, the engine's stock room server runs.
|
|
72
|
+
//
|
|
73
|
+
// The predicate is `isServerBacked` — the SAME one publish uses. If dev asked a
|
|
74
|
+
// different question (e.g. "does a partykit.json exist", which nothing in this
|
|
75
|
+
// CLI ever creates), dev and publish would disagree and a creator's primitive
|
|
76
|
+
// would run in production but not on their own machine. One question, one
|
|
77
|
+
// answer, both commands.
|
|
78
|
+
|
|
79
|
+
// `share` opens a public link to the stack for testers (share.mjs); `openShare`
|
|
80
|
+
// replaces the tunnel for tests.
|
|
81
|
+
export async function dev(opts = {}) {
|
|
82
|
+
// Whatever startup has made so far, undone if a later step throws: a room
|
|
83
|
+
// build that fails on the creator's code, a port already taken, a tunnel
|
|
84
|
+
// that never opens. A frozen copy of the game is among it.
|
|
85
|
+
const undo = [];
|
|
86
|
+
try {
|
|
87
|
+
return await startDev(opts, (fn) => undo.push(fn));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
for (const fn of undo.reverse()) {
|
|
90
|
+
try { fn(); } catch { /* the original error is the one to report */ }
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `agentSession` is the coding-agent session to wake with agent-inbox reports
|
|
97
|
+
// (agent-session.mjs). Only the interactive `joystack dev` and `joystack assets`
|
|
98
|
+
// commands pass one; a stack started by `joystack test` or any other caller has
|
|
99
|
+
// none, so no test run can wake the agent that started it.
|
|
100
|
+
async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null } = {}, onFailure) {
|
|
101
|
+
const project = findProject(cwd);
|
|
102
|
+
// Q4 revision: the engine is not an npm dependency — install/refresh it from
|
|
103
|
+
// the platform's login-gated registry (no-op when the pinned version is in).
|
|
104
|
+
const engine = await ensureEngine(project.dir, { log });
|
|
105
|
+
if (share && !engineCanShare(engine.sharedDir)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`joystack dev --share needs a newer engine than ${engine.version ?? 'the one this game is pinned to'}: ` +
|
|
108
|
+
'its pages cannot tell they were opened through the link, so they would record nothing. Run `npx joystack update`, then try again.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Any 3D model the entity definitions reference re-bakes when its GLB (or
|
|
113
|
+
// bake config) changed — the server must hit the surface the player sees. A
|
|
114
|
+
// bake failure stops the stack with the model named rather than serving a
|
|
115
|
+
// room that shoots at yesterday's shape.
|
|
116
|
+
const { ensureBaked } = await import('./model-bake.mjs');
|
|
117
|
+
await ensureBaked({ dir: project.dir, log });
|
|
118
|
+
|
|
119
|
+
// Recordings accumulate on the creator's own disk — every session played
|
|
120
|
+
// under `joystack dev` writes one — so the oldest go here, before anything can
|
|
121
|
+
// be watching one. The caps and the keep marker live in replay-store.mjs.
|
|
122
|
+
pruneOnStart({ projectDir: project.dir, log });
|
|
123
|
+
|
|
124
|
+
// `reload: false` (`joystack dev --no-reload`) freezes the game as it is now:
|
|
125
|
+
// pages are served, and the room is built, from a copy taken here, after the
|
|
126
|
+
// bake so baked models come with it, and nothing is watched. Recordings still
|
|
127
|
+
// land in the real folder. See freeze.mjs for why a copy is needed.
|
|
128
|
+
const frozen = reload ? null : freezeGame(project.dir);
|
|
129
|
+
if (frozen) onFailure(frozen.remove);
|
|
130
|
+
if (frozen) watch = false;
|
|
131
|
+
const codeDir = frozen?.dir ?? project.dir;
|
|
132
|
+
|
|
133
|
+
// Which ports, and may we take them? An explicit --port is obeyed as given
|
|
134
|
+
// (takeover included). With no flag we step around anyone else's stack —
|
|
135
|
+
// another lane, another game, an unrelated app — and only ever reclaim our
|
|
136
|
+
// OWN stale server. See ports.mjs; this is what makes a lane collision-free
|
|
137
|
+
// without the creator having to remember a flag.
|
|
138
|
+
const explicit = port ?? (readEnv('JOYSTACK_DEV_PORT') ? Number(readEnv('JOYSTACK_DEV_PORT')) : null);
|
|
139
|
+
const { ports, tookOver, steppedAside } = await resolvePorts({
|
|
140
|
+
projectDir: project.dir,
|
|
141
|
+
requested: explicit,
|
|
142
|
+
});
|
|
143
|
+
for (const s of steppedAside) {
|
|
144
|
+
const who = s.reason === 'browser-restricted'
|
|
145
|
+
? 'browser-restricted (its multiplayer port would be blocked) — stepping past'
|
|
146
|
+
: `held by ${s.projectDir ? `another Joystack game (${basename(s.projectDir)})` : 'another app'} — leaving it alone`;
|
|
147
|
+
log(`→ :${s.port} (${who})`);
|
|
148
|
+
}
|
|
149
|
+
// An EXPLICIT --port is obeyed as given, but if it derives a browser-blocked
|
|
150
|
+
// port (e.g. --port 8050 → mp 2049/NFS) the game would look broken with no
|
|
151
|
+
// clue, so say so loudly rather than silently binding it.
|
|
152
|
+
for (const [name, p] of [['multiplayer', ports.mp], ['static', ports.static]]) {
|
|
153
|
+
if (isRestricted(p)) {
|
|
154
|
+
log(`⚠ ${name} port :${p} is browser-restricted (ERR_UNSAFE_PORT) — browsers will refuse it. Re-run with a --port whose ${name} port is reachable (e.g. --port ${nextBrowserSafe(ports.static)}).`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const children = [];
|
|
158
|
+
const servers = [];
|
|
159
|
+
|
|
160
|
+
// The game's own server code, if any. Built BEFORE any server binds a port —
|
|
161
|
+
// both because a malformed primitives folder should fail before we start
|
|
162
|
+
// taking over ports, and because this generates the merged browser registry
|
|
163
|
+
// the static server is about to mount. Building it after would leave a window
|
|
164
|
+
// where the page loads and predicts with the engine's classes instead.
|
|
165
|
+
//
|
|
166
|
+
// The inert-override check runs FIRST, before the scan. An override of, say,
|
|
167
|
+
// `primitives/effects.js` is a room-runtime file that merely happens to live in
|
|
168
|
+
// the primitives folder — scanned first, it would be reported as "a primitive
|
|
169
|
+
// with no default export", which is true and useless. Checked first, the
|
|
170
|
+
// creator is told the actual problem: the server cannot take this file.
|
|
171
|
+
assertNoInertOverride({ projectDir: codeDir, engine });
|
|
172
|
+
const serverPrimitives = scanPrimitives(codeDir);
|
|
173
|
+
const { cwd: roomServerCwd, inputs: roomInputs, skeleton: roomSkeleton = null } = await buildDevRoomServer({ projectDir: codeDir, engine });
|
|
174
|
+
const ownServer = roomServerCwd !== engine.roomServerDir;
|
|
175
|
+
// A single-player framework-v2 game (no config({ multiplayer: true })) runs its
|
|
176
|
+
// authoritative sim in-process via LocalRoom, so no room server is started.
|
|
177
|
+
const singlePlayer = isSinglePlayerV2({ projectDir: codeDir, skeleton: roomSkeleton });
|
|
178
|
+
const runsRoom = !(noMp || singlePlayer);
|
|
179
|
+
let tunnel = null;
|
|
180
|
+
|
|
181
|
+
let stopped = false;
|
|
182
|
+
const stop = () => {
|
|
183
|
+
stopped = true;
|
|
184
|
+
tunnel?.close();
|
|
185
|
+
for (const s of servers) s.close();
|
|
186
|
+
for (const c of children) terminateChildTree(c);
|
|
187
|
+
frozen?.remove();
|
|
188
|
+
};
|
|
189
|
+
onFailure(stop);
|
|
190
|
+
|
|
191
|
+
// ─── static ───
|
|
192
|
+
// `tookOver` is the ONLY licence to kill: an explicit --port, or our own
|
|
193
|
+
// stale server on this block. resolvePorts already walked away from
|
|
194
|
+
// everyone else's, so anything still here is fair game.
|
|
195
|
+
if (tookOver && (await portInUse(ports.static))) {
|
|
196
|
+
log(`→ static :${ports.static} (our stale server — reclaiming)`);
|
|
197
|
+
await killPort(ports.static);
|
|
198
|
+
}
|
|
199
|
+
// overrides/shared/… : the game's engine modifications mount ahead of the
|
|
200
|
+
// bundle and shadow it per-file — the same shadowing the publish resolve step
|
|
201
|
+
// applies in production.
|
|
202
|
+
//
|
|
203
|
+
// Two override layers, in precedence order:
|
|
204
|
+
// 1. GENERATED (.joystack/overrides/shared) — the merged primitive registry the
|
|
205
|
+
// CLI writes for a game with server primitives, so the client predicts
|
|
206
|
+
// with the game's classes. It goes first because it shadows a file
|
|
207
|
+
// (primitives/index.js) the creator is not allowed to write themselves.
|
|
208
|
+
// 2. THE CREATOR'S (overrides/shared) — everything they wrote.
|
|
209
|
+
// Then the engine. First mount that HAS the file wins (static-server.mjs).
|
|
210
|
+
const generatedOverridesDir = join(stateDir(codeDir), 'overrides', 'shared');
|
|
211
|
+
const overridesDir = join(codeDir, 'overrides', 'shared');
|
|
212
|
+
const staticServer = createStaticServer({
|
|
213
|
+
slug: project.slug,
|
|
214
|
+
projectDir: project.dir, // answers /__joystack/whoami — see ports.mjs
|
|
215
|
+
log, // agent-inbox report lines ride the dev logger
|
|
216
|
+
engineVersion: engine.version ?? null, // stamped on agent-inbox reports
|
|
217
|
+
agentSession, // woken by agent-inbox reports sent from this machine
|
|
218
|
+
mounts: [
|
|
219
|
+
{ url: `/games/${project.slug}/`, dir: codeDir },
|
|
220
|
+
{ url: '/shared/', dir: generatedOverridesDir },
|
|
221
|
+
{ url: '/shared/', dir: overridesDir },
|
|
222
|
+
{ url: '/shared/', dir: engine.sharedDir },
|
|
223
|
+
],
|
|
224
|
+
watchDirs: watch ? [codeDir, engine.sharedDir] : [],
|
|
225
|
+
injectReload: watch,
|
|
226
|
+
...(frozen ? { fixedBuild: frozen.build } : {}),
|
|
227
|
+
// Present only for a framework-v2 game — injects the joystack import map + the
|
|
228
|
+
// lowered skeleton into the entry HTML so startGame() boots in the browser.
|
|
229
|
+
skeleton: roomSkeleton,
|
|
230
|
+
// Where the room's websocket is forwarded (the upgrade handler), so a page
|
|
231
|
+
// reached through the share link joins it on the static port alone.
|
|
232
|
+
roomPort: runsRoom ? ports.mp : null,
|
|
233
|
+
});
|
|
234
|
+
await staticServer.listen(ports.static);
|
|
235
|
+
servers.push(staticServer);
|
|
236
|
+
log(`→ static :${ports.static} (serving ${frozen ? `a copy of ${project.dir} as it is now` : project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
|
|
237
|
+
|
|
238
|
+
// ─── multiplayer (partykit on the bundle's room code) ───
|
|
239
|
+
// A single-player game's client never opens a socket, so standing up partykit
|
|
240
|
+
// here just burns a port and a workerd process. Skip it, mirroring publish,
|
|
241
|
+
// which deploys no per-game room worker for the same game.
|
|
242
|
+
if (!runsRoom) {
|
|
243
|
+
log(
|
|
244
|
+
singlePlayer && !noMp
|
|
245
|
+
? `→ multiplayer :${ports.mp} (skipped — single-player; add config({ multiplayer: true }) to enable)`
|
|
246
|
+
: `→ multiplayer :${ports.mp} (skipped — NO_MP=1)`,
|
|
247
|
+
);
|
|
248
|
+
// No room worker, but the client still assembles its graph against the injected
|
|
249
|
+
// skeleton — so a STRUCTURAL edit (new entity/trait) must rebuild the skeleton
|
|
250
|
+
// and push it to the static server, or the page reloads onto a stale skeleton
|
|
251
|
+
// and assembleGraph throws ("re-ran N ticks but the skeleton has M"). The full
|
|
252
|
+
// partykit reloader below does this as a side effect; single-player needs a
|
|
253
|
+
// stripped-down version with no child to respawn. (NO_MP on a real multiplayer
|
|
254
|
+
// game has no client to serve a skeleton to, so it needs none.)
|
|
255
|
+
if (singlePlayer && watch) {
|
|
256
|
+
const skelWatcher = createFileWatcher({
|
|
257
|
+
files: roomInputs,
|
|
258
|
+
dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
|
|
259
|
+
onChange: async () => {
|
|
260
|
+
try {
|
|
261
|
+
const built = await buildDevRoomServer({ projectDir: codeDir, engine });
|
|
262
|
+
staticServer.setSkeleton(built.skeleton ?? null);
|
|
263
|
+
skelWatcher.setFiles(built.inputs);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
log(`[skeleton] rebuild failed — fix the error and save again: ${err.message}`);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
servers.push({ close: () => skelWatcher.close() });
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
if (tookOver && (await portInUse(ports.mp))) {
|
|
273
|
+
log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
|
|
274
|
+
await killPort(ports.mp);
|
|
275
|
+
}
|
|
276
|
+
const spawnMp = (cwd) => {
|
|
277
|
+
const pk = spawn(
|
|
278
|
+
process.execPath,
|
|
279
|
+
[partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `joystack-partykit-${ports.mp}`)],
|
|
280
|
+
{ cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: withoutAgentSession(process.env) },
|
|
281
|
+
);
|
|
282
|
+
pk.stdout.on('data', () => {});
|
|
283
|
+
pk.stderr.on('data', (d) => {
|
|
284
|
+
const s = String(d);
|
|
285
|
+
if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
|
|
286
|
+
});
|
|
287
|
+
// A spawn that fails (ENOENT, EMFILE) emits 'error' and never 'exit';
|
|
288
|
+
// without a listener the event throws and takes the stack down.
|
|
289
|
+
pk.on('error', (err) => log(`[partykit] failed to start: ${err.message}`));
|
|
290
|
+
children.push(pk);
|
|
291
|
+
return pk;
|
|
292
|
+
};
|
|
293
|
+
const killMp = async (pk) => {
|
|
294
|
+
if (pk.pid == null) return;
|
|
295
|
+
// Keep ownership when termination fails so stop() can retry the tree.
|
|
296
|
+
// Child exit callbacks cannot run between these synchronous statements.
|
|
297
|
+
terminateChildTree(pk);
|
|
298
|
+
const idx = children.indexOf(pk);
|
|
299
|
+
if (idx !== -1) children.splice(idx, 1);
|
|
300
|
+
const exited = new Promise((resolveExit) => {
|
|
301
|
+
if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
|
|
302
|
+
const onExit = () => {
|
|
303
|
+
pk.removeListener('exit', onExit);
|
|
304
|
+
pk.removeListener('error', onExit);
|
|
305
|
+
resolveExit();
|
|
306
|
+
};
|
|
307
|
+
pk.once('exit', onExit);
|
|
308
|
+
pk.once('error', onExit);
|
|
309
|
+
});
|
|
310
|
+
// partykit is a node wrapper forking workerd; if the tree ignores TERM,
|
|
311
|
+
// escalate — the port must be free before the replacement binds it.
|
|
312
|
+
const hardKill = setTimeout(() => {
|
|
313
|
+
try {
|
|
314
|
+
terminateChildTree(pk, 'SIGKILL');
|
|
315
|
+
} catch {
|
|
316
|
+
/* gone */
|
|
317
|
+
}
|
|
318
|
+
}, 2000);
|
|
319
|
+
hardKill.unref();
|
|
320
|
+
await exited;
|
|
321
|
+
clearTimeout(hardKill);
|
|
322
|
+
// The parent's exit is not the port's release: workerd is a separate
|
|
323
|
+
// process in the group and can hold the listening socket a beat longer.
|
|
324
|
+
// A replacement that binds too early dies on EADDRINUSE — killing a
|
|
325
|
+
// working room and leaving nothing. Wait until the port is actually free.
|
|
326
|
+
const deadline = Date.now() + 5000;
|
|
327
|
+
while ((await portInUse(ports.mp)) && Date.now() < deadline) {
|
|
328
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const pk = spawnMp(roomServerCwd);
|
|
332
|
+
// A game's own room carries one of three kinds of server code — creator
|
|
333
|
+
// primitives (named), a framework-v2 lowered bundle, or v1 entity
|
|
334
|
+
// components — and only the first has a list to print. Naming the kind
|
|
335
|
+
// keeps a v2 or component-backed game from logging a dangling "— ".
|
|
336
|
+
const ownServerDesc = serverPrimitives.length
|
|
337
|
+
? `this game's OWN server — ${serverPrimitives.map((p) => p.type).join(', ')}`
|
|
338
|
+
: `this game's OWN server — ${isFrameworkV2(codeDir) ? 'framework v2' : 'entity components'}`;
|
|
339
|
+
log(
|
|
340
|
+
`→ multiplayer :${ports.mp} (partykit on ${ownServer ? ownServerDesc : "the engine's room server"}, pid ${pk.pid})`,
|
|
341
|
+
);
|
|
342
|
+
// The stack is reported ready, and `joystack test` starts its smokes, only
|
|
343
|
+
// once the room server answers. On a game's first run partykit can take
|
|
344
|
+
// several seconds to start; a page opened before then logs a refused
|
|
345
|
+
// WebSocket connection, which fails any smoke that treats console errors
|
|
346
|
+
// as failures, even though the client reconnects moments later.
|
|
347
|
+
// A room server that exits during startup fails the stack here, naming the
|
|
348
|
+
// cause, rather than leaving every page to fail on a refused connection.
|
|
349
|
+
const room = await waitForRoomServer(ports.mp, { alive: () => pk.pid != null && pk.exitCode === null && pk.signalCode === null });
|
|
350
|
+
if (room === 'exited') throw new Error(`the multiplayer server (partykit on :${ports.mp}) stopped while starting — its errors are printed above with a [partykit] prefix`);
|
|
351
|
+
if (room === 'timeout') log(`[partykit] not answering on :${ports.mp} after 30s — the game will keep trying to connect`);
|
|
352
|
+
|
|
353
|
+
// The room bundle is frozen at build time, so the static watcher's page
|
|
354
|
+
// reload is only half the contract: an edit to anything the room runs on
|
|
355
|
+
// (entity data, a room override, a server sim) must also rebuild the room
|
|
356
|
+
// and restart its child, or the creator playtests against pre-edit
|
|
357
|
+
// adjudication with everything looking live — a failure `joystack test` is
|
|
358
|
+
// structurally blind to, because it boots fresh stacks.
|
|
359
|
+
//
|
|
360
|
+
// The watch set is the bundle's own input list (covers every file the room
|
|
361
|
+
// actually imports, wherever it lives) plus the room-shaped game trees for
|
|
362
|
+
// files that don't exist yet — a first primitive, a new entity, a new room
|
|
363
|
+
// override. overrides/ is deliberately scoped to its room subtree: editing
|
|
364
|
+
// a purely client-side override must not restart the room and reset its
|
|
365
|
+
// state mid-playtest.
|
|
366
|
+
let roomWatcher = null;
|
|
367
|
+
let roomCwd = roomServerCwd;
|
|
368
|
+
const reloader = createRoomReloader({
|
|
369
|
+
build: () => buildDevRoomServer({ projectDir: codeDir, engine }),
|
|
370
|
+
spawnChild: spawnMp,
|
|
371
|
+
killChild: killMp,
|
|
372
|
+
log,
|
|
373
|
+
onRebuilt: (built) => {
|
|
374
|
+
roomWatcher?.setFiles(built.inputs);
|
|
375
|
+
// Push the rebuilt skeleton to the static server so the client injects
|
|
376
|
+
// the SAME lowered graph the room now embeds — otherwise a v2 hot-reload
|
|
377
|
+
// leaves the page on the startup skeleton and assembleGraph throws on the
|
|
378
|
+
// next load (client "re-ran N ticks but the skeleton has M").
|
|
379
|
+
staticServer.setSkeleton(built.skeleton ?? null);
|
|
380
|
+
staticServer.roomRestarted();
|
|
381
|
+
// A rebuild can flip which server the game runs on (first primitive
|
|
382
|
+
// added, last one removed) — say so, or the startup line misleads for
|
|
383
|
+
// the rest of the session.
|
|
384
|
+
if (built.cwd !== roomCwd) {
|
|
385
|
+
roomCwd = built.cwd;
|
|
386
|
+
log(
|
|
387
|
+
`→ multiplayer :${ports.mp} (now on ${roomCwd === engine.roomServerDir ? "the engine's room server" : "this game's OWN server"})`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
reloader.attach(roomServerCwd, pk);
|
|
393
|
+
roomWatcher = watch ? createFileWatcher({
|
|
394
|
+
files: roomInputs,
|
|
395
|
+
dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
|
|
396
|
+
onChange: reloader.onChange,
|
|
397
|
+
}) : null;
|
|
398
|
+
// stop() (e.g. `joystack test` tearing down its stack) must also stop the
|
|
399
|
+
// reloader: a rebuild in flight would otherwise spawn a replacement room
|
|
400
|
+
// child AFTER the children list was killed — a leaked detached process.
|
|
401
|
+
servers.push({
|
|
402
|
+
close: () => {
|
|
403
|
+
reloader.stop();
|
|
404
|
+
roomWatcher?.close();
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const url = `http://localhost:${ports.static}/games/${project.slug}/index.html`;
|
|
410
|
+
const ip = lanIp();
|
|
411
|
+
log('');
|
|
412
|
+
log('────────────────────────────────────────────────────────────');
|
|
413
|
+
log(`✅ ${project.slug} ready. Open:`);
|
|
414
|
+
log(` ${url}`);
|
|
415
|
+
if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
|
|
416
|
+
// Only when this game declares assets (inspect.mjs). A line printed on every
|
|
417
|
+
// single boot is a line nobody reads by the third one, and the tool it points
|
|
418
|
+
// at is then invisible exactly when it would have helped.
|
|
419
|
+
if (hasDeclaredAssets(project.dir)) {
|
|
420
|
+
log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
|
|
421
|
+
}
|
|
422
|
+
if (share) {
|
|
423
|
+
log('');
|
|
424
|
+
log('→ share opening a public link (cloudflared)…');
|
|
425
|
+
tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
|
|
426
|
+
staticServer.setShareHost(tunnel.hostname);
|
|
427
|
+
for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: !!frozen })) log(line);
|
|
428
|
+
tunnel.exited.then(() => {
|
|
429
|
+
if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
log('────────────────────────────────────────────────────────────');
|
|
433
|
+
|
|
434
|
+
return { project, engine, ports, url, shareUrl: tunnel ? `${tunnel.url}/games/${project.slug}/index.html` : null, frozenDir: frozen?.dir ?? null, stop };
|
|
435
|
+
}
|
package/lib/engine.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// ensureEngine — make the engine present before dev/publish (Q4 revision:
|
|
2
|
+
// the engine is NOT an npm package; npm ships only this CLI).
|
|
3
|
+
//
|
|
4
|
+
// The engine installs from the platform's release registry via the
|
|
5
|
+
// LOGIN-GATED /api/creator/engine lane — every download maps to a creator
|
|
6
|
+
// account (attribution, revocation, and the /activate approval as the terms
|
|
7
|
+
// gate). The flow, Playwright-style (npm tool + vendor-served artifact):
|
|
8
|
+
//
|
|
9
|
+
// 1. honor the game's `joystack.engine` pin in package.json (written on first
|
|
10
|
+
// install, so repeat runs are reproducible and offline-friendly)
|
|
11
|
+
// 2. if the installed node_modules/@joystack/engine already matches → done
|
|
12
|
+
// 3. otherwise: auto-run the device-flow login when the machine has no
|
|
13
|
+
// token, download the release tarball (cached in ~/.joystack/cache — a
|
|
14
|
+
// later `npm install` may prune the engine from node_modules; the cache
|
|
15
|
+
// makes the self-heal local), `npm install --no-save` it, write the pin.
|
|
16
|
+
//
|
|
17
|
+
// JOYSTACK_ENGINE_TARBALL=<path> short-circuits the network — the offline/smoke
|
|
18
|
+
// lane (packed-install smoke, air-gapped work against a local build).
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { runNpm } from './npm.mjs';
|
|
22
|
+
import { resolveEngine } from './project.mjs';
|
|
23
|
+
import { cacheDir, DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
|
|
24
|
+
import { login } from './login.mjs';
|
|
25
|
+
import { leaveJoystackNodeDoors } from './joystack-node-doors.mjs';
|
|
26
|
+
import { LEGACY_ENGINE_PACKAGE, gameConfig, gameConfigKey, readEnv } from './legacy-names.mjs';
|
|
27
|
+
|
|
28
|
+
// An engine package that installed under the name releases before 0.2.36 use.
|
|
29
|
+
function olderEngineError(projectDir, version) {
|
|
30
|
+
if (!existsSync(join(projectDir, 'node_modules', ...LEGACY_ENGINE_PACKAGE.split('/'), 'package.json'))) return null;
|
|
31
|
+
return new Error(
|
|
32
|
+
`engine ${version} predates this CLI, which runs engine 0.2.36 and later: pin a newer engine in package.json and retry.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readEnginePin(projectDir) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
39
|
+
const pin = gameConfig(pkg).engine;
|
|
40
|
+
return typeof pin === 'string' ? pin : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function writeEnginePin(projectDir, version) {
|
|
47
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
48
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
49
|
+
const key = gameConfigKey(pkg);
|
|
50
|
+
pkg[key] = { ...pkg[key], engine: version };
|
|
51
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function installedEngine(projectDir) {
|
|
55
|
+
try {
|
|
56
|
+
return resolveEngine(projectDir);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `npm install --no-save <tgz>` — npm extracts the package into
|
|
63
|
+
// node_modules/@joystack/engine AND installs its runtime dependencies
|
|
64
|
+
// (the room server's bare imports), without touching package.json.
|
|
65
|
+
function npmInstallTarball(projectDir, tgzPath) {
|
|
66
|
+
runNpm(['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
|
|
67
|
+
cwd: projectDir,
|
|
68
|
+
stdio: 'pipe',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The engine, present — plus the `node_modules/joystack` package that tells anyone
|
|
74
|
+
* running a test file with bare `node` to use `joystack test` instead
|
|
75
|
+
* (joystack-node-doors.mjs).
|
|
76
|
+
*
|
|
77
|
+
* The moment those doors are wanted is the moment somebody runs a test file by
|
|
78
|
+
* hand, which is not a moment any CLI command is watching — so they are left
|
|
79
|
+
* behind in advance, by whatever ran last. `create` and `dev` come through
|
|
80
|
+
* here; `joystack test` does not, and writes them itself (test-cmd.mjs).
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureEngine(projectDir, opts = {}) {
|
|
83
|
+
const engine = await installEngine(projectDir, opts);
|
|
84
|
+
leaveJoystackNodeDoors(projectDir, engine);
|
|
85
|
+
return engine;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function installEngine(
|
|
89
|
+
projectDir,
|
|
90
|
+
{
|
|
91
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
92
|
+
log = console.log,
|
|
93
|
+
fetchImpl = fetch,
|
|
94
|
+
loginFn = login,
|
|
95
|
+
installTarball = npmInstallTarball,
|
|
96
|
+
tarballOverride = readEnv('JOYSTACK_ENGINE_TARBALL'),
|
|
97
|
+
dirOverride = readEnv('JOYSTACK_ENGINE_DIR'),
|
|
98
|
+
} = {},
|
|
99
|
+
) {
|
|
100
|
+
// Before the pin, before the installed copy, before the network: an explicit
|
|
101
|
+
// checkout is the most specific thing anyone can ask for.
|
|
102
|
+
if (dirOverride) {
|
|
103
|
+
// resolveEngine owns the override for EVERY tool (project.mjs) — this only
|
|
104
|
+
// announces it, so the banner and the lint can never disagree about which
|
|
105
|
+
// engine is in play.
|
|
106
|
+
const e = resolveEngine(projectDir);
|
|
107
|
+
log(`→ engine ${e.version} from JOYSTACK_ENGINE_DIR (${e.dir}) — nothing installed`);
|
|
108
|
+
return e;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let pin = readEnginePin(projectDir);
|
|
112
|
+
|
|
113
|
+
const installed = installedEngine(projectDir);
|
|
114
|
+
if (installed && (!pin || installed.version === pin)) {
|
|
115
|
+
// Adopt a pre-existing install (e.g. the npm-distribution era) as the pin.
|
|
116
|
+
if (!pin) writeEnginePin(projectDir, installed.version);
|
|
117
|
+
return installed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Offline/smoke lane: a local tarball wins over everything network.
|
|
121
|
+
if (tarballOverride) {
|
|
122
|
+
log(`Installing the engine from ${tarballOverride}…`);
|
|
123
|
+
installTarball(projectDir, tarballOverride);
|
|
124
|
+
const engine = installedEngine(projectDir);
|
|
125
|
+
if (!engine) throw olderEngineError(projectDir, tarballOverride) ?? new Error(`installing ${tarballOverride} did not produce node_modules/@joystack/engine`);
|
|
126
|
+
if (pin && engine.version !== pin) {
|
|
127
|
+
log(` (note: this tarball is ${engine.version}; the game pinned ${pin} — pin updated)`);
|
|
128
|
+
}
|
|
129
|
+
writeEnginePin(projectDir, engine.version);
|
|
130
|
+
return engine;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The download lane is login-gated: no token → run the device flow now.
|
|
134
|
+
if (!getToken()) {
|
|
135
|
+
log('The Joystack engine is downloaded from the platform and needs your account.');
|
|
136
|
+
await loginFn({ apiBase, log });
|
|
137
|
+
if (!getToken()) throw new Error('login did not produce a token — run `joystack login` and retry.');
|
|
138
|
+
}
|
|
139
|
+
const auth = { Authorization: `Bearer ${getToken()}` };
|
|
140
|
+
|
|
141
|
+
if (!pin) {
|
|
142
|
+
const res = await fetchImpl(`${apiBase}/api/creator/engine`, { headers: auth });
|
|
143
|
+
if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
|
|
144
|
+
if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
|
|
145
|
+
const { latest } = await res.json();
|
|
146
|
+
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
147
|
+
pin = latest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cache = cacheDir();
|
|
151
|
+
const cached = join(cache, `engine-${pin}.tgz`);
|
|
152
|
+
// A release candidate (a `-suffix` pin) is MUTABLE — the same label can be
|
|
153
|
+
// re-cut with new bytes — so its cache entry can be stale. Always re-download
|
|
154
|
+
// a candidate; the content-addressed stable releases are safe to cache forever.
|
|
155
|
+
const isCandidate = pin.includes('-');
|
|
156
|
+
if (!existsSync(cached) || isCandidate) {
|
|
157
|
+
log(`Downloading engine ${pin} from ${apiBase}…`);
|
|
158
|
+
const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
|
|
159
|
+
let res = await fetchImpl(dlUrl, { headers: auth });
|
|
160
|
+
// 403 = the token is fine but the platform refuses with a reason the
|
|
161
|
+
// human must act on — today, an updated Engine License that needs
|
|
162
|
+
// re-accepting. The acceptance IS the browser approval step of the device
|
|
163
|
+
// flow, so run the flow right here instead of asking the human to type
|
|
164
|
+
// `joystack login` themselves (the click in the browser is still theirs to
|
|
165
|
+
// make), then retry once with the fresh token. If the platform still says
|
|
166
|
+
// no, surface its words — never loop.
|
|
167
|
+
if (res.status === 403) {
|
|
168
|
+
const reason = (await res.json().catch(() => null))?.error;
|
|
169
|
+
log(reason || 'The platform refused the engine download — your sign-in needs a refresh.');
|
|
170
|
+
log('Opening the sign-in flow to sort that out…');
|
|
171
|
+
await loginFn({ apiBase, log });
|
|
172
|
+
res = await fetchImpl(dlUrl, { headers: { Authorization: `Bearer ${getToken()}` } });
|
|
173
|
+
if (res.status === 403) {
|
|
174
|
+
const still = (await res.json().catch(() => null))?.error;
|
|
175
|
+
throw new Error(still || reason || 'the platform refused the engine download (HTTP 403).');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
|
|
179
|
+
if (res.status === 404) throw new Error(`engine ${pin} is not downloadable — check the \`joystack.engine\` pin in package.json.`);
|
|
180
|
+
if (!res.ok) throw new Error(`engine download failed (HTTP ${res.status})`);
|
|
181
|
+
mkdirSync(cache, { recursive: true });
|
|
182
|
+
// Write-then-rename so a killed download never leaves a truncated .tgz
|
|
183
|
+
// behind as a valid-looking cache entry.
|
|
184
|
+
const partial = `${cached}.partial`;
|
|
185
|
+
writeFileSync(partial, Buffer.from(await res.arrayBuffer()));
|
|
186
|
+
renameSync(partial, cached);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
log(`Installing engine ${pin}…`);
|
|
190
|
+
installTarball(projectDir, cached);
|
|
191
|
+
const engine = installedEngine(projectDir);
|
|
192
|
+
if (!engine) throw olderEngineError(projectDir, pin) ?? new Error('engine install did not produce node_modules/@joystack/engine');
|
|
193
|
+
if (engine.version !== pin) {
|
|
194
|
+
throw new Error(`installed engine reports ${engine.version} but ${pin} was requested — corrupt cache? Delete ${cached} and retry.`);
|
|
195
|
+
}
|
|
196
|
+
writeEnginePin(projectDir, pin);
|
|
197
|
+
return engine;
|
|
198
|
+
}
|