@looop-games/cli 0.1.24 → 0.1.25

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 CHANGED
@@ -14,6 +14,27 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.25] - 2026-08-01
18
+
19
+ ### Fixed
20
+
21
+ - `looop dev` now rebuilds and restarts the multiplayer room when you edit a
22
+ file the room depends on — your primitives and their helpers, entity data
23
+ (`entities/`), components, and `overrides/`. Before, only the page reloaded:
24
+ the room kept adjudicating with your pre-edit code and pre-edit spawned
25
+ entities until you killed and relaunched the stack, so a change could look
26
+ like it did nothing. Room state resets on such an edit (the world reseeds
27
+ when the page rejoins); if the rebuild fails, the previous room keeps
28
+ running and the error is printed. This also means a game that gains its
29
+ first primitive while `dev` is running now gets its own room server
30
+ immediately, instead of silently staying on the stock one.
31
+ - `looop dev` could keep running your pre-edit code after you changed a file,
32
+ with no error anywhere — the game just looked like your change did nothing.
33
+ Import URLs are now stamped so that editing any file renames the URL of every
34
+ module that (directly or indirectly) imports it, which forces the browser to
35
+ refetch the changed part of the game on the next reload instead of silently
36
+ reusing what it had cached.
37
+
17
38
  ## [0.1.24] - 2026-08-01
18
39
 
19
40
  ### Changed
package/lib/dev.mjs CHANGED
@@ -19,6 +19,7 @@ import { getToken, getApiBase } from './config.mjs';
19
19
  import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
20
  import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
+ import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
22
23
 
23
24
  // Resolve the partykit CLI entry from OUR dependencies (the game never
24
25
  // declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
@@ -90,7 +91,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
90
91
  // creator is told the actual problem: the server cannot take this file.
91
92
  assertNoInertOverride({ projectDir: project.dir, engine });
92
93
  const serverPrimitives = scanPrimitives(project.dir);
93
- const roomServerCwd = await buildDevRoomServer({ projectDir: project.dir, engine });
94
+ const { cwd: roomServerCwd, inputs: roomInputs } = await buildDevRoomServer({ projectDir: project.dir, engine });
94
95
  const ownServer = roomServerCwd !== engine.roomServerDir;
95
96
 
96
97
  const stop = () => {
@@ -153,20 +154,115 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
153
154
  log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
154
155
  await killPort(ports.mp);
155
156
  }
156
- const pk = spawn(
157
- process.execPath,
158
- [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
159
- { cwd: roomServerCwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
160
- );
161
- pk.stdout.on('data', () => {});
162
- pk.stderr.on('data', (d) => {
163
- const s = String(d);
164
- if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
165
- });
166
- children.push(pk);
157
+ const spawnMp = (cwd) => {
158
+ const pk = spawn(
159
+ process.execPath,
160
+ [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
161
+ { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
162
+ );
163
+ pk.stdout.on('data', () => {});
164
+ pk.stderr.on('data', (d) => {
165
+ const s = String(d);
166
+ if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
167
+ });
168
+ // A spawn that fails (ENOENT, EMFILE) emits 'error' and never 'exit';
169
+ // without a listener the event throws and takes the stack down.
170
+ pk.on('error', (err) => log(`[partykit] failed to start: ${err.message}`));
171
+ children.push(pk);
172
+ return pk;
173
+ };
174
+ const killMp = async (pk) => {
175
+ const idx = children.indexOf(pk);
176
+ if (idx !== -1) children.splice(idx, 1);
177
+ if (pk.pid == null) return; // never started — nothing to reap, and 'exit' will never fire
178
+ const exited = new Promise((resolveExit) => {
179
+ if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
180
+ pk.once('exit', resolveExit);
181
+ pk.once('error', resolveExit);
182
+ });
183
+ try {
184
+ process.kill(-pk.pid, 'SIGTERM');
185
+ } catch {
186
+ try {
187
+ pk.kill('SIGTERM');
188
+ } catch {
189
+ /* gone */
190
+ }
191
+ }
192
+ // partykit is a node wrapper forking workerd; if the tree ignores TERM,
193
+ // escalate — the port must be free before the replacement binds it.
194
+ const hardKill = setTimeout(() => {
195
+ try {
196
+ process.kill(-pk.pid, 'SIGKILL');
197
+ } catch {
198
+ /* gone */
199
+ }
200
+ }, 2000);
201
+ hardKill.unref();
202
+ await exited;
203
+ clearTimeout(hardKill);
204
+ // The parent's exit is not the port's release: workerd is a separate
205
+ // process in the group and can hold the listening socket a beat longer.
206
+ // A replacement that binds too early dies on EADDRINUSE — killing a
207
+ // working room and leaving nothing. Wait until the port is actually free.
208
+ const deadline = Date.now() + 5000;
209
+ while ((await portInUse(ports.mp)) && Date.now() < deadline) {
210
+ await new Promise((r) => setTimeout(r, 100));
211
+ }
212
+ };
213
+ const pk = spawnMp(roomServerCwd);
167
214
  log(
168
215
  `→ multiplayer :${ports.mp} (partykit on ${ownServer ? `this game's OWN server — ${serverPrimitives.map((p) => p.type).join(', ')}` : "the engine's room server"}, pid ${pk.pid})`,
169
216
  );
217
+
218
+ // The room bundle is frozen at build time, so the static watcher's page
219
+ // reload is only half the contract: an edit to anything the room runs on
220
+ // (entity data, a room override, a server sim) must also rebuild the room
221
+ // and restart its child, or the creator playtests against pre-edit
222
+ // adjudication with everything looking live — a failure `looop test` is
223
+ // structurally blind to, because it boots fresh stacks.
224
+ //
225
+ // The watch set is the bundle's own input list (covers every file the room
226
+ // actually imports, wherever it lives) plus the room-shaped game trees for
227
+ // files that don't exist yet — a first primitive, a new entity, a new room
228
+ // override. overrides/ is deliberately scoped to its room subtree: editing
229
+ // a purely client-side override must not restart the room and reset its
230
+ // state mid-playtest.
231
+ let roomWatcher = null;
232
+ let roomCwd = roomServerCwd;
233
+ const reloader = createRoomReloader({
234
+ build: () => buildDevRoomServer({ projectDir: project.dir, engine }),
235
+ spawnChild: spawnMp,
236
+ killChild: killMp,
237
+ log,
238
+ onRebuilt: (built) => {
239
+ roomWatcher?.setFiles(built.inputs);
240
+ // A rebuild can flip which server the game runs on (first primitive
241
+ // added, last one removed) — say so, or the startup line misleads for
242
+ // the rest of the session.
243
+ if (built.cwd !== roomCwd) {
244
+ roomCwd = built.cwd;
245
+ log(
246
+ `→ multiplayer :${ports.mp} (now on ${roomCwd === engine.roomServerDir ? "the engine's room server" : "this game's OWN server"})`,
247
+ );
248
+ }
249
+ },
250
+ });
251
+ reloader.attach(roomServerCwd, pk);
252
+ roomWatcher = createFileWatcher({
253
+ files: roomInputs,
254
+ dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
255
+ onChange: reloader.onChange,
256
+ });
257
+ // stop() (e.g. `looop test` tearing down its stack) must also stop the
258
+ // reloader: a rebuild in flight would otherwise spawn a replacement room
259
+ // child AFTER the children list was killed — a leaked detached process.
260
+ servers.push({
261
+ close: () => {
262
+ reloader.stop();
263
+ roomWatcher.close();
264
+ },
265
+ });
170
266
  }
171
267
 
172
268
  // ─── platform services shim ───
package/lib/inject.mjs CHANGED
@@ -5,10 +5,10 @@
5
5
  // (classic scripts, so globals exist before any module runs) + the ONE
6
6
  // platform-layer module tag. New platform features are added to
7
7
  // shared/platform/platform.js FEATURES — never here.
8
- // * ?v=<mtime> rewriting on <script src> and JS imports busts Chrome's
8
+ // * ?v=<stamp> rewriting on <script src> and JS imports busts Chrome's
9
9
  // sticky module cache on edit.
10
- import { statSync } from 'node:fs';
11
- import { join, dirname, normalize } from 'node:path';
10
+ import { readFileSync, statSync } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
12
 
13
13
  // MUST match builder/functions/_shared/serve-identity.ts DEV_IDENTITY.
14
14
  export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
@@ -39,36 +39,137 @@ const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
39
39
  // Match <script src="X"> in HTML.
40
40
  const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
41
41
 
42
- // Resolve `relPath` and return `<relPath>?v=<mtime-seconds>` or null.
43
- // `resolveUrl(urlPath)` maps an absolute URL path through the server's mounts.
44
- function versionedPath(relPath, { baseDir, resolveUrl, allowBareRelative = false }) {
45
- if (/^(https?:)?\/\//.test(relPath) || relPath.startsWith('data:') || relPath.startsWith('blob:')) return null;
46
- if (relPath.includes('?') || relPath.includes('#')) return null;
47
- if (!/\.(js|mjs)$/.test(relPath)) return null;
48
- let target;
49
- if (relPath.startsWith('/')) target = resolveUrl(relPath);
50
- else if (relPath.startsWith('.')) target = normalize(join(baseDir, relPath));
51
- else if (allowBareRelative) target = normalize(join(baseDir, relPath));
52
- else return null; // bare module specifier
53
- if (!target) return null;
42
+ // A spec that can never name a local file the server would stamp: absolute
43
+ // URLs, data:/blob:, and anything already carrying a query or fragment.
44
+ function isExternalSpec(spec) {
45
+ return (
46
+ /^(https?:)?\/\//.test(spec) ||
47
+ spec.startsWith('data:') ||
48
+ spec.startsWith('blob:') ||
49
+ spec.includes('?') ||
50
+ spec.includes('#')
51
+ );
52
+ }
53
+
54
+ // Resolve an import specifier the way the BROWSER does — against the importing
55
+ // module's URL — returning an absolute URL path, or null for bare/external
56
+ // specs. Stamping must live in URL space because that is where module identity
57
+ // lives: an engine file's relative sibling import can be shadowed by the
58
+ // game's overrides mount, so the file the URL serves is not the file that sits
59
+ // next to the importer on disk.
60
+ function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
61
+ if (isExternalSpec(spec)) return null;
62
+ if (spec.startsWith('/')) return spec;
63
+ if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
64
+ try {
65
+ return new URL(spec, 'http://x' + baseUrl).pathname;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // The version stamp is the max mtime over the module's TRANSITIVE static
72
+ // import closure, not the file's own mtime. Module identity is the full URL,
73
+ // and a browser may reuse a cached body for an unchanged URL across reloads
74
+ // (Chrome's memory cache does not honor no-store for same-URL subresources).
75
+ // A per-target stamp renames only the edited file's URL — its importers' URLs
76
+ // stay put, so a reloading page reassembles the whole pre-edit graph from
77
+ // cache with zero requests and no error anywhere. Stamping the closure renames
78
+ // every URL on the path from the entry (which a navigation always re-fetches),
79
+ // so no URL-keyed cache can serve stale bytes.
80
+ //
81
+ // The parse cache stores RAW specifiers keyed by file path + mtime; resolution
82
+ // happens per walk, because the same file's specs resolve differently through
83
+ // different mount tables (two servers in one process must not contaminate each
84
+ // other). Boundary: only string-literal static/dynamic imports are seen —
85
+ // import(expr) and new URL(...) are runtime-computed and stay unstamped.
86
+ const parseCache = new Map(); // abs fs path -> { mtimeMs, specs: string[] }
87
+
88
+ function fileInfo(path) {
89
+ let st;
54
90
  try {
55
- const mtime = Math.floor(statSync(target).mtimeMs / 1000);
56
- return `${relPath}?v=${mtime}`;
91
+ st = statSync(path);
57
92
  } catch {
58
93
  return null;
59
94
  }
95
+ const hit = parseCache.get(path);
96
+ if (hit && hit.mtimeMs === st.mtimeMs) return hit;
97
+ const specs = [];
98
+ if (/\.(js|mjs)$/.test(path)) {
99
+ let text = null;
100
+ try {
101
+ text = readFileSync(path, 'utf8');
102
+ } catch {
103
+ /* unreadable: stamp from its own mtime alone */
104
+ }
105
+ if (text !== null) {
106
+ for (const m of text.matchAll(JS_IMPORT_RE)) {
107
+ if (!isExternalSpec(m[3])) specs.push(m[3]);
108
+ }
109
+ }
110
+ }
111
+ const info = { mtimeMs: st.mtimeMs, specs };
112
+ parseCache.set(path, info);
113
+ return info;
114
+ }
115
+
116
+ // Walk in URL space: each node is a URL path, mapped to bytes through the
117
+ // server's mount table. `memo` (shared across one rewrite pass) short-circuits
118
+ // only entry nodes — a spec repeated in one body — not intermediates.
119
+ function closureStampUrl(urlPath, resolveUrl, memo) {
120
+ if (memo?.has(urlPath)) return memo.get(urlPath);
121
+ let max = 0;
122
+ const seen = new Set();
123
+ const stack = [urlPath];
124
+ while (stack.length) {
125
+ const u = stack.pop();
126
+ if (seen.has(u)) continue;
127
+ seen.add(u);
128
+ const fs = resolveUrl(u);
129
+ if (!fs) continue;
130
+ const info = fileInfo(fs);
131
+ if (!info) continue;
132
+ if (info.mtimeMs > max) max = info.mtimeMs;
133
+ for (const spec of info.specs) {
134
+ const child = resolveSpecToUrl(spec, u);
135
+ if (child) stack.push(child);
136
+ }
137
+ }
138
+ const stamp = max ? Math.floor(max) : null;
139
+ memo?.set(urlPath, stamp);
140
+ return stamp;
141
+ }
142
+
143
+ // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
144
+ // extensions include .json: entity/config data is imported by games, and its
145
+ // URL must rename on edit exactly like a module's.
146
+ //
147
+ // `baseUrl` + `resolveUrl` are REQUIRED for stamping: there is deliberately no
148
+ // disk-space fallback. Resolving a relative spec beside the importer on disk
149
+ // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
150
+ // which silently re-opens the staleness hole. No serving context → no stamp.
151
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, memo }) {
152
+ if (!/\.(js|mjs|json)$/.test(relPath)) return null;
153
+ if (baseUrl == null || !resolveUrl) return null;
154
+ const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
155
+ if (!urlTarget) return null;
156
+ if (!resolveUrl(urlTarget)) return null; // nothing serves it — leave the spec alone
157
+ const stamp = closureStampUrl(urlTarget, resolveUrl, memo);
158
+ return stamp === null ? null : `${relPath}?v=${stamp}`;
60
159
  }
61
160
 
62
- export function rewriteJsImports(text, { baseDir, resolveUrl }) {
161
+ export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
162
+ const memo = new Map();
63
163
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
64
- const v = versionedPath(path, { baseDir, resolveUrl });
164
+ const v = versionedPath(path, { baseUrl, resolveUrl, memo });
65
165
  return `${prefix}${quote}${v ?? path}${quote}`;
66
166
  });
67
167
  }
68
168
 
69
- export function rewriteHtmlScripts(html, { baseDir, resolveUrl }) {
169
+ export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
170
+ const memo = new Map();
70
171
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
71
- const v = versionedPath(path, { baseDir, resolveUrl, allowBareRelative: true });
172
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, memo });
72
173
  return `${prefix}${v ?? path}${suffix}`;
73
174
  });
74
175
  }
@@ -0,0 +1,168 @@
1
+ // Room hot-reload for `looop 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 (`looop 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
+ }
@@ -24,7 +24,7 @@
24
24
  // `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
25
25
  // Same seam, two deliveries.)
26
26
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
- import { dirname, join, relative } from 'node:path';
27
+ import { dirname, isAbsolute, join, relative } from 'node:path';
28
28
  import { pathToFileURL } from 'node:url';
29
29
  import {
30
30
  assertNoOverrideCycle,
@@ -141,8 +141,11 @@ function renderEntry(primitives, entityComponents) {
141
141
  ].join('\n');
142
142
  }
143
143
 
144
- // → the cwd to run partykit in. The engine's stock room server when the game has
145
- // no primitives of its own; a freshly-built one when it does.
144
+ // → `{ cwd, inputs }`: the cwd to run partykit in the engine's stock room
145
+ // server when the game has no primitives of its own, a freshly-built one when
146
+ // it does — plus the absolute paths of every file bundled into it. The room
147
+ // bundle is frozen at build time, so `inputs` is what dev watches to know the
148
+ // running room no longer matches the files on disk.
146
149
  //
147
150
  // Throws if the primitives dir is malformed (e.g. a hand-written index.js) —
148
151
  // dev must not quietly serve a room that is missing the creator's code.
@@ -157,7 +160,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
157
160
  // loading. The mount is unconditional (dev.mjs), so the cleanup has to be too.
158
161
  rmSync(join(projectDir, DEV_ROOM_SERVER_DIR), { recursive: true, force: true });
159
162
  rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
160
- return engine.roomServerDir;
163
+ return { cwd: engine.roomServerDir, inputs: [] };
161
164
  }
162
165
  assertNoOverrideCycle(primitives, projectDir, engine.sharedDir);
163
166
 
@@ -165,7 +168,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
165
168
  const outDir = join(projectDir, DEV_ROOM_SERVER_DIR);
166
169
  mkdirSync(outDir, { recursive: true });
167
170
 
168
- await esbuild.build({
171
+ const result = await esbuild.build({
169
172
  // No entry file on disk: the barrel + subclass are fed straight in, resolved
170
173
  // against the game root, so `./overrides/...` means what it says and no
171
174
  // machine-specific absolute path can leak into the output.
@@ -184,6 +187,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
184
187
  legalComments: 'none',
185
188
  conditions: ['workerd', 'worker'],
186
189
  loader: { '.wasm': 'binary' },
190
+ metafile: true,
187
191
  absWorkingDir: projectDir,
188
192
  plugins: [
189
193
  // Shadowing, for the bundler: an override's relative imports fall through
@@ -227,7 +231,13 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
227
231
  rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
228
232
  }
229
233
 
230
- return outDir;
234
+ // Metafile inputs are relative to absWorkingDir (the game root); the stdin
235
+ // entry (keyed by its `sourcefile` name, no file on disk) is excluded.
236
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
237
+ .filter((p) => p !== 'looop-room-server.js' && !p.startsWith('<'))
238
+ .map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
239
+
240
+ return { cwd: outDir, inputs };
231
241
  }
232
242
 
233
243
  export { isServerBacked };
@@ -149,8 +149,7 @@ export function createStaticServer({
149
149
 
150
150
  function serveHtml(res, fsPath, urlPath, query) {
151
151
  let html = readFileSync(fsPath, 'utf8');
152
- const baseDir = join(fsPath, '..');
153
- html = rewriteHtmlScripts(html, { baseDir, resolveUrl });
152
+ html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
154
153
  // Mirror the production /g/<slug> entry injection — only when the resolved
155
154
  // engine actually ships the platform layer (transition shim, same as
156
155
  // dev_server.py _engine_has_platform).
@@ -173,11 +172,11 @@ export function createStaticServer({
173
172
  sendBody(res, 200, html, MIME['.html']);
174
173
  }
175
174
 
176
- function serveJs(res, fsPath) {
175
+ function serveJs(res, fsPath, urlPath) {
177
176
  const raw = readFileSync(fsPath);
178
177
  let body;
179
178
  try {
180
- body = rewriteJsImports(raw.toString('utf8'), { baseDir: join(fsPath, '..'), resolveUrl });
179
+ body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl });
181
180
  } catch {
182
181
  body = raw;
183
182
  }
@@ -244,7 +243,7 @@ export function createStaticServer({
244
243
  const ext = extname(fsPath).toLowerCase();
245
244
  try {
246
245
  if (ext === '.html') return serveHtml(res, fsPath, urlPath, reqUrl.searchParams);
247
- if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath);
246
+ if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath, urlPath);
248
247
  sendBody(res, 200, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream');
249
248
  } catch {
250
249
  res.writeHead(500, { 'Content-Type': 'text/plain' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",