@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Room hot-reload for `joystack dev`.
|
|
2
|
+
//
|
|
3
|
+
// The room bundle (room-server.mjs) is frozen at build time and partykit runs
|
|
4
|
+
// it as-is, so an edit to a room-authoritative file — entity data, an override,
|
|
5
|
+
// a server sim, a manifest — reaches the client (page reload) but not the room
|
|
6
|
+
// unless something rebuilds it. This module is that something: a poll watcher
|
|
7
|
+
// over the bundle's actual inputs plus the room-shaped game trees, and a
|
|
8
|
+
// reloader that rebuilds and restarts the partykit child on change.
|
|
9
|
+
//
|
|
10
|
+
// Restarting the child drops the room's in-memory world on purpose: the state
|
|
11
|
+
// was seeded from pre-edit files, and the page reload the static watcher
|
|
12
|
+
// already fires makes the next join reseed from the fresh ones. A rebuild that
|
|
13
|
+
// FAILS must never take the working room down with it — the creator gets the
|
|
14
|
+
// old room and a loud message, not a dead stack.
|
|
15
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { extname, join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
// Extensions that can be a room input when watching a whole dir. Explicit
|
|
19
|
+
// files (bundle metafile inputs) are watched regardless of extension.
|
|
20
|
+
const ROOM_EXTS = new Set(['.js', '.mjs', '.json', '.wasm']);
|
|
21
|
+
|
|
22
|
+
function snapshot(files, dirs) {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
try {
|
|
26
|
+
out.set(f, statSync(f).mtimeMs);
|
|
27
|
+
} catch {
|
|
28
|
+
out.set(f, -1); // a vanished input is a change too
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const walk = (dir) => {
|
|
32
|
+
let names;
|
|
33
|
+
try {
|
|
34
|
+
names = readdirSync(dir);
|
|
35
|
+
} catch {
|
|
36
|
+
return; // the tree may not exist yet — its first file appearing is a change
|
|
37
|
+
}
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
40
|
+
const p = join(dir, name);
|
|
41
|
+
let st;
|
|
42
|
+
try {
|
|
43
|
+
st = statSync(p);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (st.isDirectory()) walk(p);
|
|
48
|
+
else if (ROOM_EXTS.has(extname(name).toLowerCase())) out.set(p, st.mtimeMs);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
for (const d of dirs) walk(d);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function differs(a, b) {
|
|
56
|
+
if (a.size !== b.size) return true;
|
|
57
|
+
for (const [k, v] of a) if (b.get(k) !== v) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createFileWatcher({ files = [], dirs = [], intervalMs = 400, onChange = null }) {
|
|
62
|
+
// Two independent baselines: the explicit file list (which setFiles swaps
|
|
63
|
+
// between rebuilds) and the dir walk (which setFiles must never touch). One
|
|
64
|
+
// merged map couldn't tell a dropped input apart from a dir-walk entry.
|
|
65
|
+
let watchedFiles = [...files];
|
|
66
|
+
const watchedDirs = [...dirs];
|
|
67
|
+
let fileSnap = snapshot(watchedFiles, []);
|
|
68
|
+
let dirSnap = snapshot([], watchedDirs);
|
|
69
|
+
const oneShots = [];
|
|
70
|
+
const timer = setInterval(() => {
|
|
71
|
+
const nextFiles = snapshot(watchedFiles, []);
|
|
72
|
+
const nextDirs = snapshot([], watchedDirs);
|
|
73
|
+
if (differs(fileSnap, nextFiles) || differs(dirSnap, nextDirs)) {
|
|
74
|
+
fileSnap = nextFiles;
|
|
75
|
+
dirSnap = nextDirs;
|
|
76
|
+
while (oneShots.length) oneShots.pop()();
|
|
77
|
+
// Fire-and-forget on purpose, but a rejection out of the handler must
|
|
78
|
+
// not become an unhandled rejection that takes the dev stack down.
|
|
79
|
+
try {
|
|
80
|
+
Promise.resolve(onChange?.()).catch(() => {});
|
|
81
|
+
} catch {
|
|
82
|
+
/* handler threw synchronously — the handler owns its own reporting */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}, intervalMs);
|
|
86
|
+
timer.unref();
|
|
87
|
+
return {
|
|
88
|
+
setFiles(next) {
|
|
89
|
+
// Swap the file LIST without rebaselining mtimes: an edit that landed
|
|
90
|
+
// after the last poll (mid-rebuild, before this call) must still diff
|
|
91
|
+
// on the next tick. Only genuinely new paths get a fresh stat; entries
|
|
92
|
+
// already tracked keep their last-observed mtime.
|
|
93
|
+
const merged = new Map();
|
|
94
|
+
for (const f of next) {
|
|
95
|
+
if (fileSnap.has(f)) merged.set(f, fileSnap.get(f));
|
|
96
|
+
else merged.set(f, snapshot([f], []).get(f));
|
|
97
|
+
}
|
|
98
|
+
watchedFiles = [...next];
|
|
99
|
+
fileSnap = merged;
|
|
100
|
+
},
|
|
101
|
+
onNextChange(fn) {
|
|
102
|
+
oneShots.push(fn);
|
|
103
|
+
},
|
|
104
|
+
close() {
|
|
105
|
+
clearInterval(timer);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createRoomReloader({ build, spawnChild, killChild, log, onRebuilt = null }) {
|
|
111
|
+
let current = null; // { cwd, child }
|
|
112
|
+
let running = false;
|
|
113
|
+
let dirty = false;
|
|
114
|
+
let stopped = false;
|
|
115
|
+
|
|
116
|
+
async function onChange() {
|
|
117
|
+
if (stopped) return;
|
|
118
|
+
if (running) {
|
|
119
|
+
dirty = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
running = true;
|
|
123
|
+
try {
|
|
124
|
+
do {
|
|
125
|
+
dirty = false;
|
|
126
|
+
let built;
|
|
127
|
+
try {
|
|
128
|
+
built = await build();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log(`✗ room rebuild failed — the running room still serves your PREVIOUS code:\n${err.message}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// stop() may race any await in here (`joystack test` tearing the stack
|
|
134
|
+
// down, SIGINT): once stopped, spawning would leak a detached room
|
|
135
|
+
// child forever. The check must repeat AFTER the kill too — killChild
|
|
136
|
+
// waits for the child's exit and can take seconds (SIGTERM, then the
|
|
137
|
+
// SIGKILL escalation), which is the widest window a stop() can land in.
|
|
138
|
+
if (stopped) return;
|
|
139
|
+
try {
|
|
140
|
+
await killChild(current.child);
|
|
141
|
+
if (stopped) return;
|
|
142
|
+
const child = spawnChild(built.cwd);
|
|
143
|
+
current = { cwd: built.cwd, child };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log(`✗ room restart failed — fix the cause, then edit any room file to retry:\n${err.message}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
onRebuilt?.(built);
|
|
149
|
+
log('→ room server rebuilt and restarted (room state reseeds on the next join)');
|
|
150
|
+
} while (dirty && !stopped);
|
|
151
|
+
} finally {
|
|
152
|
+
running = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
attach(cwd, child) {
|
|
158
|
+
current = { cwd, child };
|
|
159
|
+
},
|
|
160
|
+
onChange,
|
|
161
|
+
stop() {
|
|
162
|
+
stopped = true;
|
|
163
|
+
},
|
|
164
|
+
get current() {
|
|
165
|
+
return current;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|