@joystack/cli 0.1.45 → 0.1.47
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 +33 -0
- package/README.md +3 -2
- package/bin/joystack.mjs +5 -0
- package/lib/dev.mjs +268 -113
- package/lib/freeze.mjs +81 -25
- package/lib/live-versions.mjs +212 -0
- package/lib/setup-guide.md +122 -0
- package/lib/setup.mjs +9 -0
- package/lib/static-server.mjs +208 -110
- package/package.json +1 -1
- package/lib/room-reload.mjs +0 -168
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,39 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.47] - 2026-10-09
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `joystack setup` prints the guide an AI agent follows to make your first
|
|
22
|
+
game: ask what to call it, run `create`, then hand off into the new folder.
|
|
23
|
+
It works from any folder, before you have signed in, so the way to start is
|
|
24
|
+
now to ask your agent to run `npx -y joystack@latest setup` and follow what
|
|
25
|
+
it prints.
|
|
26
|
+
|
|
27
|
+
## [0.1.46] - 2026-10-09
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `joystack dev` serves one version of your game at a time. Once your files
|
|
32
|
+
stop changing, it copies them, and the page, every file the page loads
|
|
33
|
+
afterwards, and the multiplayer room all come from that copy. A running page
|
|
34
|
+
can no longer meet files that changed under it, such as an entity that was
|
|
35
|
+
deleted or a layout that no longer matches the code.
|
|
36
|
+
- While a new version is being prepared, the page shows "Updating…". If the
|
|
37
|
+
game does not build, the page says what is wrong in one line instead of
|
|
38
|
+
showing the previous game, and updates again on your next save. The full
|
|
39
|
+
error is printed in the terminal.
|
|
40
|
+
- The multiplayer room reloads only when the code it runs has changed, and
|
|
41
|
+
in place rather than by restarting the server. An edit to the page alone
|
|
42
|
+
keeps the room and its state.
|
|
43
|
+
- A save reaches the open page sooner: `joystack dev` hears about the change
|
|
44
|
+
from the system instead of checking every 400 ms, a save that touches
|
|
45
|
+
nothing the game's code imports (a page, a style, an image) skips the
|
|
46
|
+
build, and a new version copies only the files that changed.
|
|
47
|
+
- `GET /__joystack/dev` on the dev server reports whether it is up to date
|
|
48
|
+
and, after a failed build, the error. Your agent can read it after an edit.
|
|
49
|
+
|
|
17
50
|
## [0.1.45] - 2026-10-09
|
|
18
51
|
|
|
19
52
|
### Changed
|
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@ cd my-game
|
|
|
9
9
|
npx joystack dev
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
New here?
|
|
13
|
-
|
|
12
|
+
New here? Ask your AI agent to run **`npx -y joystack@latest setup`** and follow
|
|
13
|
+
what it prints. It asks what to call your game, creates the folder, and tells
|
|
14
|
+
you how to carry on from inside it.
|
|
14
15
|
|
|
15
16
|
## License
|
|
16
17
|
|
package/bin/joystack.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { lane } from '../lib/lane.mjs';
|
|
|
9
9
|
import { login, logout, whoami } from '../lib/login.mjs';
|
|
10
10
|
import { publish } from '../lib/publish.mjs';
|
|
11
11
|
import { create } from '../lib/create.mjs';
|
|
12
|
+
import { setupGuide } from '../lib/setup.mjs';
|
|
12
13
|
import { parseTestArgs, TEST_HELP } from '../lib/test-policy.mjs';
|
|
13
14
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
14
15
|
import { lintCmd } from '../lib/lint.mjs';
|
|
@@ -28,6 +29,7 @@ function flag(name) {
|
|
|
28
29
|
const HELP = `joystack — build and run Joystack games
|
|
29
30
|
|
|
30
31
|
Usage:
|
|
32
|
+
joystack setup The guide an AI agent follows to make your first game folder
|
|
31
33
|
joystack create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
32
34
|
joystack dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
33
35
|
joystack dev --share ...and print a public link testers can play (needs cloudflared)
|
|
@@ -190,6 +192,9 @@ try {
|
|
|
190
192
|
console.log(`\nWork in it:\n cd ${dir} && npx joystack dev\n`);
|
|
191
193
|
break;
|
|
192
194
|
}
|
|
195
|
+
case 'setup':
|
|
196
|
+
process.stdout.write(setupGuide());
|
|
197
|
+
break;
|
|
193
198
|
case 'create':
|
|
194
199
|
await create({ name: rest.find((a) => !a.startsWith('--')) });
|
|
195
200
|
break;
|
package/lib/dev.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `joystack dev` — the standalone two-service dev stack:
|
|
2
2
|
//
|
|
3
|
-
// static :8000 game
|
|
4
|
-
// head injection + auto-reload
|
|
3
|
+
// static :8000 a version of the game at /games/<slug>/, /shared/ → engine
|
|
4
|
+
// bundle, head injection + auto-reload (live-versions.mjs)
|
|
5
5
|
// mp :1999 local partykit running the BUNDLE's room-server code
|
|
6
6
|
//
|
|
7
7
|
// Multiplayer runs from minute one — the platform is not optional (the Next
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { terminateChildTree } from './process-tree.mjs';
|
|
11
11
|
import { withoutAgentSession } from './agent-session.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
14
|
import { tmpdir } from 'node:os';
|
|
14
|
-
import { join, dirname, basename } from 'node:path';
|
|
15
|
+
import { join, dirname, basename, isAbsolute, relative, sep } from 'node:path';
|
|
15
16
|
import { findProject } from './project.mjs';
|
|
16
17
|
import { ensureEngine } from './engine.mjs';
|
|
17
18
|
import { createStaticServer } from './static-server.mjs';
|
|
@@ -19,11 +20,15 @@ import { pruneOnStart } from './replay-store.mjs';
|
|
|
19
20
|
import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
|
|
20
21
|
import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, scanPrimitives } from './primitives.mjs';
|
|
21
22
|
import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
|
|
22
|
-
import {
|
|
23
|
+
import { createLiveVersions, snapshotFiles } from './live-versions.mjs';
|
|
23
24
|
import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
|
|
24
25
|
import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
|
|
25
|
-
import { freezeGame } from './freeze.mjs';
|
|
26
|
-
import { readEnv, stateDir } from './legacy-names.mjs';
|
|
26
|
+
import { freezeGame, frozenFiles } from './freeze.mjs';
|
|
27
|
+
import { STATE_DIR_NAMES, readEnv, stateDir } from './legacy-names.mjs';
|
|
28
|
+
|
|
29
|
+
// The route a room under `joystack dev` answers with the name of the build it
|
|
30
|
+
// runs (installRoom in startDev).
|
|
31
|
+
const ROOM_BUILD_PATH = '/__joystack/room-build';
|
|
27
32
|
|
|
28
33
|
// Resolve the partykit CLI entry from OUR dependencies (the game never
|
|
29
34
|
// declares partykit; it rides @joystack/cli). partykit's `exports` map hides
|
|
@@ -121,14 +126,97 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
121
126
|
// be watching one. The caps and the keep marker live in replay-store.mjs.
|
|
122
127
|
pruneOnStart({ projectDir: project.dir, log });
|
|
123
128
|
|
|
124
|
-
// `
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
129
|
+
// `watch` (an interactive `joystack dev`) serves versions of the game: copies
|
|
130
|
+
// taken as the files settle, each with the room and the entity layout built
|
|
131
|
+
// from it (live-versions.mjs). `reload: false` (`--no-reload`) starts with
|
|
132
|
+
// auto-reload off. A stack started without `watch` (`joystack test`, the
|
|
133
|
+
// asset tools) runs once against the folder itself.
|
|
134
|
+
const live = watch;
|
|
135
|
+
let nextVersion = 1;
|
|
136
|
+
const makeVersion = async ({ files, previous = null, bake = true }) => {
|
|
137
|
+
if (bake) await ensureBaked({ dir: project.dir, log });
|
|
138
|
+
const copy = freezeGame(project.dir, { files, from: previous });
|
|
139
|
+
try {
|
|
140
|
+
const reused = previous && reusableRoom(previous, files) ? reuseRoom(previous.room, copy.dir) : null;
|
|
141
|
+
return { id: nextVersion++, dir: copy.dir, build: copy.build, remove: copy.remove, room: reused ?? await buildRoom(copy.dir) };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Errors name the copy's files, under either spelling of its path; the
|
|
144
|
+
// person reading them edits the game's.
|
|
145
|
+
const named = (text) => [[realpathSync(copy.dir), realpathSync(project.dir)], [copy.dir, project.dir]]
|
|
146
|
+
.reduce((t, [from, to]) => t.split(from).join(to), String(text));
|
|
147
|
+
err.message = named(err.message);
|
|
148
|
+
copy.remove();
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
// The inert-override check runs before the build: an override of, say,
|
|
153
|
+
// `primitives/effects.js` is a room-runtime file that merely happens to live
|
|
154
|
+
// in the primitives folder, and the scan would report it as "a primitive
|
|
155
|
+
// with no default export", which is true and useless.
|
|
156
|
+
//
|
|
157
|
+
// `key` names what the room runs: the bundle and the entity layout. The
|
|
158
|
+
// bundle names its source files in comments, by paths that run through each
|
|
159
|
+
// copy's own folder, so those are left out of it. `inputs` are the files the
|
|
160
|
+
// build read, written from the game folder.
|
|
161
|
+
const buildRoom = async (dir) => {
|
|
162
|
+
assertNoInertOverride({ projectDir: dir, engine });
|
|
163
|
+
const built = await buildDevRoomServer({ projectDir: dir, engine });
|
|
164
|
+
const bundle = built.cwd === engine.roomServerDir ? 'engine' : readFileSync(join(built.cwd, 'server.js'), 'utf8').replace(/frozen[/\\][^/\\]+[/\\]/g, '');
|
|
165
|
+
const key = createHash('sha256').update(bundle).update('\0').update(JSON.stringify(built.skeleton ?? null)).digest('hex');
|
|
166
|
+
const inputs = new Set((built.inputs ?? []).map((p) => relative(dir, p).split(sep).join('/')));
|
|
167
|
+
return { ...built, key, inputs, v2: isFrameworkV2(dir) };
|
|
168
|
+
};
|
|
169
|
+
// A framework-v2 game's room and layout are built from the world file and
|
|
170
|
+
// what it imports, and nothing else in the folder: a save that touches none
|
|
171
|
+
// of those (a page, a style, an image, a file nothing imports) keeps the
|
|
172
|
+
// previous build. The engine's files, package.json and overrides/ always
|
|
173
|
+
// count, since they change how the build itself runs.
|
|
174
|
+
const reusableRoom = (previous, files) => {
|
|
175
|
+
if (!previous.room.v2) return false;
|
|
176
|
+
const changed = new Set();
|
|
177
|
+
for (const [k, v] of files) if (previous.files.get(k) !== v) changed.add(k);
|
|
178
|
+
for (const k of previous.files.keys()) if (!files.has(k)) changed.add(k);
|
|
179
|
+
for (const path of changed) {
|
|
180
|
+
const rel = relative(project.dir, path).split(sep).join('/');
|
|
181
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return false;
|
|
182
|
+
if (rel === 'package.json' || rel.startsWith('overrides/') || previous.room.inputs.has(rel)) return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
};
|
|
186
|
+
// The previous build, carried into a new copy: the files the build wrote
|
|
187
|
+
// into its room-server folder are linked, so the copy holds everything its
|
|
188
|
+
// version runs on. The folders in there are the room server's own, made
|
|
189
|
+
// while it runs, and stay behind.
|
|
190
|
+
const reuseRoom = (room, dir) => {
|
|
191
|
+
if (room.cwd === engine.roomServerDir) return room;
|
|
192
|
+
const to = join(stateDir(dir), 'room-server');
|
|
193
|
+
mkdirSync(to, { recursive: true });
|
|
194
|
+
for (const name of readdirSync(room.cwd)) {
|
|
195
|
+
const from = join(room.cwd, name);
|
|
196
|
+
if (!lstatSync(from).isFile()) continue;
|
|
197
|
+
try {
|
|
198
|
+
linkSync(from, join(to, name));
|
|
199
|
+
} catch {
|
|
200
|
+
copyFileSync(from, join(to, name));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { ...room, cwd: to };
|
|
204
|
+
};
|
|
205
|
+
// What a version is made from: the game's files, and the engine's, which the
|
|
206
|
+
// room bundle includes. Engine files are served as they are, not copied.
|
|
207
|
+
const sourceFiles = () => {
|
|
208
|
+
const files = frozenFiles(project.dir);
|
|
209
|
+
for (const [k, v] of snapshotFiles([engine.sharedDir], (name) => name.startsWith('.') || name === 'node_modules')) files.set(k, v);
|
|
210
|
+
return files;
|
|
211
|
+
};
|
|
212
|
+
let first = null;
|
|
213
|
+
if (live) {
|
|
214
|
+
const files = sourceFiles();
|
|
215
|
+
first = await makeVersion({ files, bake: false });
|
|
216
|
+
first.files = files;
|
|
217
|
+
onFailure(first.remove);
|
|
218
|
+
}
|
|
219
|
+
const codeDir = first?.dir ?? project.dir;
|
|
132
220
|
|
|
133
221
|
// Which ports, and may we take them? An explicit --port is obeyed as given
|
|
134
222
|
// (takeover included). With no flag we step around anyone else's stack —
|
|
@@ -162,29 +250,35 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
162
250
|
// taking over ports, and because this generates the merged browser registry
|
|
163
251
|
// the static server is about to mount. Building it after would leave a window
|
|
164
252
|
// 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
253
|
const serverPrimitives = scanPrimitives(codeDir);
|
|
173
|
-
const { cwd: roomServerCwd,
|
|
254
|
+
const { cwd: roomServerCwd, skeleton: roomSkeleton = null } = first?.room ?? await buildRoom(codeDir);
|
|
174
255
|
const ownServer = roomServerCwd !== engine.roomServerDir;
|
|
175
256
|
// A single-player framework-v2 game (no config({ multiplayer: true })) runs its
|
|
176
257
|
// authoritative sim in-process via LocalRoom, so no room server is started.
|
|
177
258
|
const singlePlayer = isSinglePlayerV2({ projectDir: codeDir, skeleton: roomSkeleton });
|
|
178
259
|
const runsRoom = !(noMp || singlePlayer);
|
|
179
260
|
let tunnel = null;
|
|
261
|
+
// Moves the multiplayer room onto a version (set below when a room runs).
|
|
262
|
+
let moveRoom = async () => {};
|
|
180
263
|
|
|
181
264
|
let stopped = false;
|
|
265
|
+
let versions = null;
|
|
266
|
+
// What the room runs (a version's room.key). It can be an older version's
|
|
267
|
+
// than the page's: a version whose room build is the same keeps the room
|
|
268
|
+
// running, with its state.
|
|
269
|
+
let roomKey = first?.room.key ?? null;
|
|
270
|
+
// Where the room runs from under `joystack dev`: a folder of its own that each
|
|
271
|
+
// version's room build is written into, so the running room server reloads
|
|
272
|
+
// it in place rather than being restarted (moveRoom).
|
|
273
|
+
const liveRoomDir = join(stateDir(project.dir), 'room-live');
|
|
182
274
|
const stop = () => {
|
|
183
275
|
stopped = true;
|
|
276
|
+
versions?.close();
|
|
184
277
|
tunnel?.close();
|
|
185
278
|
for (const s of servers) s.close();
|
|
186
279
|
for (const c of children) terminateChildTree(c);
|
|
187
|
-
|
|
280
|
+
versions?.current.remove();
|
|
281
|
+
first?.remove();
|
|
188
282
|
};
|
|
189
283
|
onFailure(stop);
|
|
190
284
|
|
|
@@ -207,23 +301,25 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
207
301
|
// (primitives/index.js) the creator is not allowed to write themselves.
|
|
208
302
|
// 2. THE CREATOR'S (overrides/shared) — everything they wrote.
|
|
209
303
|
// Then the engine. First mount that HAS the file wins (static-server.mjs).
|
|
210
|
-
const
|
|
211
|
-
|
|
304
|
+
const mountsFor = (dir) => [
|
|
305
|
+
{ url: `/games/${project.slug}/`, dir },
|
|
306
|
+
{ url: '/shared/', dir: join(stateDir(dir), 'overrides', 'shared') },
|
|
307
|
+
{ url: '/shared/', dir: join(dir, 'overrides', 'shared') },
|
|
308
|
+
{ url: '/shared/', dir: engine.sharedDir },
|
|
309
|
+
];
|
|
212
310
|
const staticServer = createStaticServer({
|
|
213
311
|
slug: project.slug,
|
|
214
312
|
projectDir: project.dir, // answers /__joystack/whoami — see ports.mjs
|
|
215
313
|
log, // agent-inbox report lines ride the dev logger
|
|
216
314
|
engineVersion: engine.version ?? null, // stamped on agent-inbox reports
|
|
217
315
|
agentSession, // woken by agent-inbox reports sent from this machine
|
|
218
|
-
mounts:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
{
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
injectReload: watch,
|
|
226
|
-
...(frozen ? { fixedBuild: frozen.build } : {}),
|
|
316
|
+
mounts: mountsFor(codeDir),
|
|
317
|
+
...(first ? { fixedBuild: first.build } : {}),
|
|
318
|
+
versions: live ? {
|
|
319
|
+
status: () => versions?.status ?? { state: 'ready', auto: reload, pending: false, error: null },
|
|
320
|
+
reload: () => versions?.reload(),
|
|
321
|
+
setAuto: (on) => versions?.setAuto(on),
|
|
322
|
+
} : null,
|
|
227
323
|
// Present only for a framework-v2 game — injects the joystack import map + the
|
|
228
324
|
// lowered skeleton into the entry HTML so startGame() boots in the browser.
|
|
229
325
|
skeleton: roomSkeleton,
|
|
@@ -233,7 +329,8 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
233
329
|
});
|
|
234
330
|
await staticServer.listen(ports.static);
|
|
235
331
|
servers.push(staticServer);
|
|
236
|
-
|
|
332
|
+
if (first) staticServer.setGame({ id: first.id, mounts: mountsFor(first.dir), skeleton: roomSkeleton, build: first.build });
|
|
333
|
+
log(`→ static :${ports.static} (serving ${live ? `${project.dir} as it is when its files settle` : project.dir}, engine ${engine.version}, auto-reload ${live && reload ? 'on' : 'off'})`);
|
|
237
334
|
|
|
238
335
|
// ─── multiplayer (partykit on the bundle's room code) ───
|
|
239
336
|
// A single-player game's client never opens a socket, so standing up partykit
|
|
@@ -245,29 +342,6 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
245
342
|
? `→ multiplayer :${ports.mp} (skipped — single-player; add config({ multiplayer: true }) to enable)`
|
|
246
343
|
: `→ multiplayer :${ports.mp} (skipped — NO_MP=1)`,
|
|
247
344
|
);
|
|
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
345
|
} else {
|
|
272
346
|
if (tookOver && (await portInUse(ports.mp))) {
|
|
273
347
|
log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
|
|
@@ -328,7 +402,80 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
328
402
|
await new Promise((r) => setTimeout(r, 100));
|
|
329
403
|
}
|
|
330
404
|
};
|
|
331
|
-
|
|
405
|
+
// The room server's entry under `joystack dev`: the version's bundle, plus a
|
|
406
|
+
// route that answers with a name for this build, which is how a reload in
|
|
407
|
+
// place is known to have finished.
|
|
408
|
+
let roomBuilds = 0;
|
|
409
|
+
const installRoom = (room) => {
|
|
410
|
+
const build = `${process.pid}-${++roomBuilds}`;
|
|
411
|
+
mkdirSync(liveRoomDir, { recursive: true });
|
|
412
|
+
// A file the room server has open cannot be replaced on Windows until it
|
|
413
|
+
// lets go, so a file is written under a new name and renamed into place,
|
|
414
|
+
// and when the rename is refused, written in place, a few times over if
|
|
415
|
+
// need be. A half-written file only fails a rebuild that the next write
|
|
416
|
+
// replaces; the build name below says when the right one is running.
|
|
417
|
+
const write = (name, body) => {
|
|
418
|
+
const tmp = join(liveRoomDir, `${name}.tmp`);
|
|
419
|
+
writeFileSync(tmp, body);
|
|
420
|
+
for (let i = 0; ; i++) {
|
|
421
|
+
try {
|
|
422
|
+
renameSync(tmp, join(liveRoomDir, name));
|
|
423
|
+
return;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
if (i >= 20 || !['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) throw err;
|
|
426
|
+
try {
|
|
427
|
+
writeFileSync(join(liveRoomDir, name), body);
|
|
428
|
+
rmSync(tmp, { force: true });
|
|
429
|
+
return;
|
|
430
|
+
} catch { /* still held */ }
|
|
431
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
// Each build's bundle under a name of its own, so the one the server
|
|
436
|
+
// runs is never written over; earlier ones go once a later one runs (pruneRoom).
|
|
437
|
+
const server = `server-${build}.js`;
|
|
438
|
+
writeFileSync(join(liveRoomDir, server), readFileSync(join(room.cwd, 'server.js')));
|
|
439
|
+
const config = JSON.parse(readFileSync(join(room.cwd, 'partykit.json'), 'utf8'));
|
|
440
|
+
write('partykit.json', `${JSON.stringify({ ...config, main: 'main.js' }, null, 2)}\n`);
|
|
441
|
+
// Written last: the room server rebuilds from it, and a rebuild that
|
|
442
|
+
// carries this build's name carries this build's bundle.
|
|
443
|
+
write('main.js', [
|
|
444
|
+
`import Room from './${server}';`,
|
|
445
|
+
'export default class extends Room {',
|
|
446
|
+
' static onFetch(req, ...rest) {',
|
|
447
|
+
` if (new URL(req.url).pathname === ${JSON.stringify(ROOM_BUILD_PATH)}) return new Response(${JSON.stringify(build)});`,
|
|
448
|
+
" return super.onFetch ? super.onFetch(req, ...rest) : new Response('Not found', { status: 404 });",
|
|
449
|
+
' }',
|
|
450
|
+
'}',
|
|
451
|
+
'',
|
|
452
|
+
].join('\n'));
|
|
453
|
+
return build;
|
|
454
|
+
};
|
|
455
|
+
// The bundles of builds before `build`, once the server runs `build`.
|
|
456
|
+
const pruneRoom = (build) => {
|
|
457
|
+
for (const name of readdirSync(liveRoomDir)) {
|
|
458
|
+
if (!/^server-.+\.js$/.test(name) || name === `server-${build}.js`) continue;
|
|
459
|
+
try {
|
|
460
|
+
rmSync(join(liveRoomDir, name), { force: true });
|
|
461
|
+
} catch { /* still open: the next prune takes it */ }
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
const roomServes = async (build, timeoutMs) => {
|
|
465
|
+
const deadline = Date.now() + timeoutMs;
|
|
466
|
+
while (Date.now() < deadline && !stopped) {
|
|
467
|
+
try {
|
|
468
|
+
const res = await fetch(`http://127.0.0.1:${ports.mp}${ROOM_BUILD_PATH}`, { signal: AbortSignal.timeout(1000) });
|
|
469
|
+
if ((await res.text()) === build) return true;
|
|
470
|
+
} catch { /* reloading */ }
|
|
471
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
};
|
|
475
|
+
const ownRoom = (room) => room.cwd !== engine.roomServerDir;
|
|
476
|
+
let runsOwn = live && ownServer;
|
|
477
|
+
if (live) rmSync(liveRoomDir, { recursive: true, force: true });
|
|
478
|
+
let pk = spawnMp(runsOwn ? (installRoom(first.room), liveRoomDir) : roomServerCwd);
|
|
332
479
|
// A game's own room carries one of three kinds of server code — creator
|
|
333
480
|
// primitives (named), a framework-v2 lowered bundle, or v1 entity
|
|
334
481
|
// components — and only the first has a list to print. Naming the kind
|
|
@@ -350,59 +497,55 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
350
497
|
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
498
|
if (room === 'timeout') log(`[partykit] not answering on :${ports.mp} after 30s — the game will keep trying to connect`);
|
|
352
499
|
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
);
|
|
500
|
+
// A new version moves the room only when what the room runs changed: an
|
|
501
|
+
// edit to a page-only file keeps the room and its state. A room of the
|
|
502
|
+
// game's own is reloaded in place by the running room server, which takes
|
|
503
|
+
// a fraction of a restart; moving between the engine's room and the
|
|
504
|
+
// game's own, or a reload that does not finish, restarts it.
|
|
505
|
+
moveRoom = async (v) => {
|
|
506
|
+
if (roomKey === v.room.key) return;
|
|
507
|
+
roomKey = null;
|
|
508
|
+
const own = ownRoom(v.room);
|
|
509
|
+
if (own && runsOwn && pk.exitCode === null && pk.signalCode === null) {
|
|
510
|
+
const build = installRoom(v.room);
|
|
511
|
+
if (await roomServes(build, 10000)) {
|
|
512
|
+
pruneRoom(build);
|
|
513
|
+
roomKey = v.room.key;
|
|
514
|
+
log(`→ multiplayer :${ports.mp} (reloaded on the new version; room state starts over)`);
|
|
515
|
+
return;
|
|
389
516
|
}
|
|
517
|
+
}
|
|
518
|
+
await killMp(pk);
|
|
519
|
+
if (stopped) return;
|
|
520
|
+
pk = spawnMp(own ? (installRoom(v.room), liveRoomDir) : engine.roomServerDir);
|
|
521
|
+
runsOwn = own;
|
|
522
|
+
const up = await waitForRoomServer(ports.mp, { alive: () => pk.pid != null && pk.exitCode === null && pk.signalCode === null });
|
|
523
|
+
if (up === 'exited') throw new Error(`the multiplayer server (partykit on :${ports.mp}) stopped while starting — its errors are printed above with a [partykit] prefix`);
|
|
524
|
+
roomKey = v.room.key;
|
|
525
|
+
log(`→ multiplayer :${ports.mp} (restarted on the new version; room state starts over)`);
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (live) {
|
|
530
|
+
versions = createLiveVersions({
|
|
531
|
+
first,
|
|
532
|
+
auto: reload,
|
|
533
|
+
log,
|
|
534
|
+
snapshot: sourceFiles,
|
|
535
|
+
make: (o) => makeVersion(o),
|
|
536
|
+
// The game folder's own events start a version. The state folder holds
|
|
537
|
+
// the copies themselves, so its events are left out.
|
|
538
|
+
watch: [{ dir: project.dir, ignore: (rel) => STATE_DIR_NAMES.some((d) => rel === d || rel.startsWith(`${d}/`)) || rel === '.git' || rel.startsWith('.git/') || rel.startsWith('node_modules/') }],
|
|
539
|
+
// Every open page has been told to leave when the update started; the
|
|
540
|
+
// switch waits until none of them is left, so no running page fetches a
|
|
541
|
+
// file from the new copy.
|
|
542
|
+
show: async (v) => {
|
|
543
|
+
await staticServer.pagesGone(v.id);
|
|
544
|
+
await moveRoom(v);
|
|
545
|
+
staticServer.setGame({ id: v.id, mounts: mountsFor(v.dir), skeleton: v.room.skeleton ?? null, build: v.build });
|
|
390
546
|
},
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
},
|
|
547
|
+
retire: (v) => v.remove(),
|
|
548
|
+
onStatus: () => staticServer.statusChanged(),
|
|
406
549
|
});
|
|
407
550
|
}
|
|
408
551
|
|
|
@@ -424,12 +567,24 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
424
567
|
log('→ share opening a public link (cloudflared)…');
|
|
425
568
|
tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
|
|
426
569
|
staticServer.setShareHost(tunnel.hostname);
|
|
427
|
-
for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen:
|
|
570
|
+
for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
|
|
428
571
|
tunnel.exited.then(() => {
|
|
429
572
|
if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
|
|
430
573
|
});
|
|
431
574
|
}
|
|
432
575
|
log('────────────────────────────────────────────────────────────');
|
|
433
576
|
|
|
434
|
-
return {
|
|
577
|
+
return {
|
|
578
|
+
project,
|
|
579
|
+
engine,
|
|
580
|
+
ports,
|
|
581
|
+
url,
|
|
582
|
+
shareUrl: tunnel ? `${tunnel.url}/games/${project.slug}/index.html` : null,
|
|
583
|
+
// The copy the page is served from now, while versions are served.
|
|
584
|
+
get frozenDir() {
|
|
585
|
+
return versions?.current.dir ?? null;
|
|
586
|
+
},
|
|
587
|
+
versions,
|
|
588
|
+
stop,
|
|
589
|
+
};
|
|
435
590
|
}
|