@looop-games/cli 0.1.24 → 0.1.26
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 +70 -0
- package/bin/looop.mjs +18 -0
- package/lib/dev.mjs +115 -12
- package/lib/engine.mjs +12 -0
- package/lib/inject.mjs +122 -21
- package/lib/inspect.mjs +71 -0
- package/lib/model-bake.mjs +49 -28
- package/lib/project.mjs +31 -0
- package/lib/publish.mjs +46 -2
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +16 -6
- package/lib/static-server.mjs +14 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,76 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.26] - 2026-08-07
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `looop inspect [<id>]` — runs the dev stack and opens the asset inspector on
|
|
22
|
+
it, so you can look at and drive one model or sound at a time. Name an asset
|
|
23
|
+
to open straight onto it; `--no-open` prints the URL instead of launching a
|
|
24
|
+
browser.
|
|
25
|
+
|
|
26
|
+
- `looop dev` now prints the inspector's link when your game declares assets (it
|
|
27
|
+
has an `assets.js`). Not on every boot: a line that always appears is one
|
|
28
|
+
nobody reads by the third time.
|
|
29
|
+
|
|
30
|
+
- `LOOOP_ENGINE_DIR=<path>` runs against an engine **checkout** where it lies,
|
|
31
|
+
installing nothing — `dev`, `test`, `lint` and the automatic model re-bake all
|
|
32
|
+
resolve the same one, so what runs in the browser and what the gate checks
|
|
33
|
+
cannot be two different engines. Only useful if you are working on the engine
|
|
34
|
+
itself: `LOOOP_ENGINE_TARBALL` installs into your project, so trying a
|
|
35
|
+
work-in-progress engine used to mean deleting your game's working engine and
|
|
36
|
+
putting it back afterwards. This leaves your project exactly as it was. Point
|
|
37
|
+
it at an engine bundle (`packages/engine/dist`); a path that is not one fails
|
|
38
|
+
loudly rather than quietly falling back to the installed copy.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **`looop publish` no longer ships files your `.gitignore` excludes.** Build
|
|
43
|
+
output, scratch files and the screenshots a test run leaves behind were being
|
|
44
|
+
uploaded and served from your public game URL. If your `.gitignore` says a path
|
|
45
|
+
is not part of the game, publish now agrees with it. (A game folder that isn't
|
|
46
|
+
a git repo publishes exactly as before.)
|
|
47
|
+
|
|
48
|
+
- `looop inspect --port <n>` opened the inspector on an asset named after the
|
|
49
|
+
port number instead of on your game's first asset.
|
|
50
|
+
|
|
51
|
+
- The dev server no longer injects the game platform layer (the boot gate, the
|
|
52
|
+
loading curtain, the identity menu) into HTML pages that are not your game.
|
|
53
|
+
Only `/games/<slug>/` gets it, which is what happens in production — before
|
|
54
|
+
this, any other page the dev server served came up behind a curtain waiting
|
|
55
|
+
for an identity it had never asked for.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- The automatic model re-bake that `dev`, `test` and `publish` run now finds
|
|
60
|
+
your models in `assets.js` rather than by scanning `entities/**/entity.json`
|
|
61
|
+
for `.glb` strings — entities name a model by asset id, so there is no path
|
|
62
|
+
left in one to scan. It is also the more complete list: a model your game
|
|
63
|
+
loads from its own code is re-baked now too, where before only a model some
|
|
64
|
+
entity referenced was.
|
|
65
|
+
|
|
66
|
+
## [0.1.25] - 2026-08-01
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- `looop dev` now rebuilds and restarts the multiplayer room when you edit a
|
|
71
|
+
file the room depends on — your primitives and their helpers, entity data
|
|
72
|
+
(`entities/`), components, and `overrides/`. Before, only the page reloaded:
|
|
73
|
+
the room kept adjudicating with your pre-edit code and pre-edit spawned
|
|
74
|
+
entities until you killed and relaunched the stack, so a change could look
|
|
75
|
+
like it did nothing. Room state resets on such an edit (the world reseeds
|
|
76
|
+
when the page rejoins); if the rebuild fails, the previous room keeps
|
|
77
|
+
running and the error is printed. This also means a game that gains its
|
|
78
|
+
first primitive while `dev` is running now gets its own room server
|
|
79
|
+
immediately, instead of silently staying on the stock one.
|
|
80
|
+
- `looop dev` could keep running your pre-edit code after you changed a file,
|
|
81
|
+
with no error anywhere — the game just looked like your change did nothing.
|
|
82
|
+
Import URLs are now stamped so that editing any file renames the URL of every
|
|
83
|
+
module that (directly or indirectly) imports it, which forces the browser to
|
|
84
|
+
refetch the changed part of the game on the next reload instead of silently
|
|
85
|
+
reusing what it had cached.
|
|
86
|
+
|
|
17
87
|
## [0.1.24] - 2026-08-01
|
|
18
88
|
|
|
19
89
|
### Changed
|
package/bin/looop.mjs
CHANGED
|
@@ -28,6 +28,7 @@ const HELP = `looop — build and run Looop games
|
|
|
28
28
|
Usage:
|
|
29
29
|
looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
30
30
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
31
|
+
looop inspect [<id>] Browse and drive your models, sounds and assets on their own
|
|
31
32
|
looop lane <name> Open an isolated copy of the game to experiment in, safely
|
|
32
33
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
33
34
|
looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
|
|
@@ -60,6 +61,23 @@ try {
|
|
|
60
61
|
// Keep the process alive; servers + children hold the loop open.
|
|
61
62
|
break;
|
|
62
63
|
}
|
|
64
|
+
case 'inspect': {
|
|
65
|
+
// The same dev stack `looop dev` runs, opened on the inspector. Not a
|
|
66
|
+
// second server: the inspector is a page under /shared/, which this one
|
|
67
|
+
// already mounts.
|
|
68
|
+
const { inspectorUrl, openBrowser, itemArg } = await import('../lib/inspect.mjs');
|
|
69
|
+
const handle = await dev({ port: flag('port') ? Number(flag('port')) : undefined });
|
|
70
|
+
const target = inspectorUrl(handle.url, { item: itemArg(rest) });
|
|
71
|
+
console.log(`\n 🔎 Inspector → ${target}\n`);
|
|
72
|
+
if (!rest.includes('--no-open')) openBrowser(target);
|
|
73
|
+
const shutdown = () => {
|
|
74
|
+
handle.stop();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
};
|
|
77
|
+
process.on('SIGINT', shutdown);
|
|
78
|
+
process.on('SIGTERM', shutdown);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
63
81
|
case 'lane': {
|
|
64
82
|
const name = rest.find((a) => !a.startsWith('--'));
|
|
65
83
|
if (!name) throw new Error('Name the lane: looop lane <name> (e.g. looop lane judder)');
|
package/lib/dev.mjs
CHANGED
|
@@ -19,6 +19,8 @@ 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';
|
|
23
|
+
import { hasInspectableAssets, inspectorUrl } from './inspect.mjs';
|
|
22
24
|
|
|
23
25
|
// Resolve the partykit CLI entry from OUR dependencies (the game never
|
|
24
26
|
// declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
|
|
@@ -90,7 +92,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
90
92
|
// creator is told the actual problem: the server cannot take this file.
|
|
91
93
|
assertNoInertOverride({ projectDir: project.dir, engine });
|
|
92
94
|
const serverPrimitives = scanPrimitives(project.dir);
|
|
93
|
-
const roomServerCwd = await buildDevRoomServer({ projectDir: project.dir, engine });
|
|
95
|
+
const { cwd: roomServerCwd, inputs: roomInputs } = await buildDevRoomServer({ projectDir: project.dir, engine });
|
|
94
96
|
const ownServer = roomServerCwd !== engine.roomServerDir;
|
|
95
97
|
|
|
96
98
|
const stop = () => {
|
|
@@ -153,20 +155,115 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
153
155
|
log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
|
|
154
156
|
await killPort(ports.mp);
|
|
155
157
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
158
|
+
const spawnMp = (cwd) => {
|
|
159
|
+
const pk = spawn(
|
|
160
|
+
process.execPath,
|
|
161
|
+
[partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
|
|
162
|
+
{ cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
|
|
163
|
+
);
|
|
164
|
+
pk.stdout.on('data', () => {});
|
|
165
|
+
pk.stderr.on('data', (d) => {
|
|
166
|
+
const s = String(d);
|
|
167
|
+
if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
|
|
168
|
+
});
|
|
169
|
+
// A spawn that fails (ENOENT, EMFILE) emits 'error' and never 'exit';
|
|
170
|
+
// without a listener the event throws and takes the stack down.
|
|
171
|
+
pk.on('error', (err) => log(`[partykit] failed to start: ${err.message}`));
|
|
172
|
+
children.push(pk);
|
|
173
|
+
return pk;
|
|
174
|
+
};
|
|
175
|
+
const killMp = async (pk) => {
|
|
176
|
+
const idx = children.indexOf(pk);
|
|
177
|
+
if (idx !== -1) children.splice(idx, 1);
|
|
178
|
+
if (pk.pid == null) return; // never started — nothing to reap, and 'exit' will never fire
|
|
179
|
+
const exited = new Promise((resolveExit) => {
|
|
180
|
+
if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
|
|
181
|
+
pk.once('exit', resolveExit);
|
|
182
|
+
pk.once('error', resolveExit);
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
process.kill(-pk.pid, 'SIGTERM');
|
|
186
|
+
} catch {
|
|
187
|
+
try {
|
|
188
|
+
pk.kill('SIGTERM');
|
|
189
|
+
} catch {
|
|
190
|
+
/* gone */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// partykit is a node wrapper forking workerd; if the tree ignores TERM,
|
|
194
|
+
// escalate — the port must be free before the replacement binds it.
|
|
195
|
+
const hardKill = setTimeout(() => {
|
|
196
|
+
try {
|
|
197
|
+
process.kill(-pk.pid, 'SIGKILL');
|
|
198
|
+
} catch {
|
|
199
|
+
/* gone */
|
|
200
|
+
}
|
|
201
|
+
}, 2000);
|
|
202
|
+
hardKill.unref();
|
|
203
|
+
await exited;
|
|
204
|
+
clearTimeout(hardKill);
|
|
205
|
+
// The parent's exit is not the port's release: workerd is a separate
|
|
206
|
+
// process in the group and can hold the listening socket a beat longer.
|
|
207
|
+
// A replacement that binds too early dies on EADDRINUSE — killing a
|
|
208
|
+
// working room and leaving nothing. Wait until the port is actually free.
|
|
209
|
+
const deadline = Date.now() + 5000;
|
|
210
|
+
while ((await portInUse(ports.mp)) && Date.now() < deadline) {
|
|
211
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const pk = spawnMp(roomServerCwd);
|
|
167
215
|
log(
|
|
168
216
|
`→ 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
217
|
);
|
|
218
|
+
|
|
219
|
+
// The room bundle is frozen at build time, so the static watcher's page
|
|
220
|
+
// reload is only half the contract: an edit to anything the room runs on
|
|
221
|
+
// (entity data, a room override, a server sim) must also rebuild the room
|
|
222
|
+
// and restart its child, or the creator playtests against pre-edit
|
|
223
|
+
// adjudication with everything looking live — a failure `looop test` is
|
|
224
|
+
// structurally blind to, because it boots fresh stacks.
|
|
225
|
+
//
|
|
226
|
+
// The watch set is the bundle's own input list (covers every file the room
|
|
227
|
+
// actually imports, wherever it lives) plus the room-shaped game trees for
|
|
228
|
+
// files that don't exist yet — a first primitive, a new entity, a new room
|
|
229
|
+
// override. overrides/ is deliberately scoped to its room subtree: editing
|
|
230
|
+
// a purely client-side override must not restart the room and reset its
|
|
231
|
+
// state mid-playtest.
|
|
232
|
+
let roomWatcher = null;
|
|
233
|
+
let roomCwd = roomServerCwd;
|
|
234
|
+
const reloader = createRoomReloader({
|
|
235
|
+
build: () => buildDevRoomServer({ projectDir: project.dir, engine }),
|
|
236
|
+
spawnChild: spawnMp,
|
|
237
|
+
killChild: killMp,
|
|
238
|
+
log,
|
|
239
|
+
onRebuilt: (built) => {
|
|
240
|
+
roomWatcher?.setFiles(built.inputs);
|
|
241
|
+
// A rebuild can flip which server the game runs on (first primitive
|
|
242
|
+
// added, last one removed) — say so, or the startup line misleads for
|
|
243
|
+
// the rest of the session.
|
|
244
|
+
if (built.cwd !== roomCwd) {
|
|
245
|
+
roomCwd = built.cwd;
|
|
246
|
+
log(
|
|
247
|
+
`→ multiplayer :${ports.mp} (now on ${roomCwd === engine.roomServerDir ? "the engine's room server" : "this game's OWN server"})`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
reloader.attach(roomServerCwd, pk);
|
|
253
|
+
roomWatcher = createFileWatcher({
|
|
254
|
+
files: roomInputs,
|
|
255
|
+
dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
|
|
256
|
+
onChange: reloader.onChange,
|
|
257
|
+
});
|
|
258
|
+
// stop() (e.g. `looop test` tearing down its stack) must also stop the
|
|
259
|
+
// reloader: a rebuild in flight would otherwise spawn a replacement room
|
|
260
|
+
// child AFTER the children list was killed — a leaked detached process.
|
|
261
|
+
servers.push({
|
|
262
|
+
close: () => {
|
|
263
|
+
reloader.stop();
|
|
264
|
+
roomWatcher.close();
|
|
265
|
+
},
|
|
266
|
+
});
|
|
170
267
|
}
|
|
171
268
|
|
|
172
269
|
// ─── platform services shim ───
|
|
@@ -187,6 +284,12 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
187
284
|
log(`✅ ${project.slug} ready. Open:`);
|
|
188
285
|
log(` ${url}`);
|
|
189
286
|
if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
|
|
287
|
+
// Only when this game declares assets (inspect.mjs). A line printed on every
|
|
288
|
+
// single boot is a line nobody reads by the third one, and the tool it points
|
|
289
|
+
// at is then invisible exactly when it would have helped.
|
|
290
|
+
if (hasInspectableAssets(project.dir)) {
|
|
291
|
+
log(` 🔍 ${inspectorUrl(url)} (your models, sounds and assets, one at a time)`);
|
|
292
|
+
}
|
|
190
293
|
log('────────────────────────────────────────────────────────────');
|
|
191
294
|
|
|
192
295
|
return { project, engine, ports, url, stop };
|
package/lib/engine.mjs
CHANGED
|
@@ -67,8 +67,20 @@ export async function ensureEngine(
|
|
|
67
67
|
loginFn = login,
|
|
68
68
|
installTarball = npmInstallTarball,
|
|
69
69
|
tarballOverride = process.env.LOOOP_ENGINE_TARBALL,
|
|
70
|
+
dirOverride = process.env.LOOOP_ENGINE_DIR,
|
|
70
71
|
} = {},
|
|
71
72
|
) {
|
|
73
|
+
// Before the pin, before the installed copy, before the network: an explicit
|
|
74
|
+
// checkout is the most specific thing anyone can ask for.
|
|
75
|
+
if (dirOverride) {
|
|
76
|
+
// resolveEngine owns the override for EVERY tool (project.mjs) — this only
|
|
77
|
+
// announces it, so the banner and the lint can never disagree about which
|
|
78
|
+
// engine is in play.
|
|
79
|
+
const e = resolveEngine(projectDir);
|
|
80
|
+
log(`→ engine ${e.version} from LOOOP_ENGINE_DIR (${e.dir}) — nothing installed`);
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
let pin = readEnginePin(projectDir);
|
|
73
85
|
|
|
74
86
|
const installed = installedEngine(projectDir);
|
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=<
|
|
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 {
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
172
|
+
const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, memo });
|
|
72
173
|
return `${prefix}${v ?? path}${suffix}`;
|
|
73
174
|
});
|
|
74
175
|
}
|
package/lib/inspect.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `looop inspect` — the dev stack, opened on the asset inspector instead of the
|
|
2
|
+
// game.
|
|
3
|
+
//
|
|
4
|
+
// The inspector is a served page under /shared/, not a separate server: the dev
|
|
5
|
+
// server already mounts the engine's shared tree, so there is nothing new to
|
|
6
|
+
// start and nothing to keep in sync. That also means the inspector still works
|
|
7
|
+
// when the game itself is broken, which is when it is most wanted.
|
|
8
|
+
//
|
|
9
|
+
// It finds the game on its own — the page asks the dev server what it is serving
|
|
10
|
+
// (/__looop/whoami) — so the URL carries no slug.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export const INSPECTOR_PATH = '/shared/ui/inspector/';
|
|
17
|
+
|
|
18
|
+
// Built from the dev server's OWN url rather than an assumed localhost:8000: a
|
|
19
|
+
// lane runs on its own port base and 8000 may belong to somebody else's server,
|
|
20
|
+
// and an inspector opened on the wrong port inspects the wrong game — or none.
|
|
21
|
+
export function inspectorUrl(gameUrl, { item } = {}) {
|
|
22
|
+
const base = new URL(gameUrl);
|
|
23
|
+
const q = item ? `?_item=${encodeURIComponent(item)}` : '';
|
|
24
|
+
return `${base.origin}${INSPECTOR_PATH}${q}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Does this game have anything for the inspector to show?
|
|
28
|
+
//
|
|
29
|
+
// The whole answer is `assets.js`, because the list IS the declaration — nothing
|
|
30
|
+
// walks the folder looking for files any more. So this is the same question as
|
|
31
|
+
// "did anyone declare anything", and it is asked for the dev banner: a line that
|
|
32
|
+
// appears on every boot is a line nobody reads by the third one, and the tool it
|
|
33
|
+
// points at is then invisible exactly when it would have helped.
|
|
34
|
+
//
|
|
35
|
+
// The file's CONTENTS are not read. A registry that declares nothing, or one
|
|
36
|
+
// that throws on import, is a case the inspector page itself reports far better
|
|
37
|
+
// than a banner line could — and reading it here would mean the dev server
|
|
38
|
+
// evaluating game code to decide how to print a URL.
|
|
39
|
+
export function hasInspectableAssets(projectDir) {
|
|
40
|
+
return !!projectDir && existsSync(join(projectDir, 'assets.js'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Flags that take a VALUE, so the value is not mistaken for the asset name.
|
|
44
|
+
// `looop inspect --port 8210` is two argv entries, and reading "the first
|
|
45
|
+
// argument that is not a flag" turns 8210 into the thing to open — on the one
|
|
46
|
+
// command whose entire job is opening the right asset.
|
|
47
|
+
const VALUED_FLAGS = new Set(['--port']);
|
|
48
|
+
|
|
49
|
+
export function itemArg(rest = []) {
|
|
50
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
51
|
+
const a = rest[i];
|
|
52
|
+
if (a.startsWith('--')) {
|
|
53
|
+
if (VALUED_FLAGS.has(a)) i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
return a;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function openBrowser(url) {
|
|
62
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
63
|
+
try {
|
|
64
|
+
// Detached and fully ignored: a browser that outlives the CLI, and one that
|
|
65
|
+
// cannot hold the dev stack's stdio open if it decides to write to it.
|
|
66
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/model-bake.mjs
CHANGED
|
@@ -57,9 +57,10 @@
|
|
|
57
57
|
// it twice.
|
|
58
58
|
|
|
59
59
|
import { createHash } from 'node:crypto';
|
|
60
|
-
import { existsSync, readFileSync, writeFileSync
|
|
60
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
61
61
|
import { join, resolve, basename, dirname } from 'node:path';
|
|
62
62
|
import { pathToFileURL } from 'node:url';
|
|
63
|
+
import { resolveEngine } from './project.mjs';
|
|
63
64
|
|
|
64
65
|
// Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
|
|
65
66
|
// meaning — it participates in the staleness hash, so old artifacts re-bake.
|
|
@@ -805,38 +806,58 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
|
|
|
805
806
|
}
|
|
806
807
|
|
|
807
808
|
// ── auto-bake: the staleness sweep dev/test/publish run ──────────────────────
|
|
808
|
-
// The game's
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
|
|
809
|
+
// The game's DECLARATION says which models matter: every `src` in `assets.js`
|
|
810
|
+
// that names a `.glb`. Paths there are GAME-ROOT-relative — the same string is
|
|
811
|
+
// the presenter's fetch URL (the page sits at the game root) — so they resolve
|
|
812
|
+
// against the game dir here too. Each declared GLB whose artifacts are missing
|
|
813
|
+
// or stale re-bakes. A bake failure is a real failure, surfaced with the model
|
|
814
|
+
// named and never swallowed: a stale artifact means the server shoots at a shape
|
|
815
|
+
// the player no longer sees.
|
|
816
|
+
//
|
|
817
|
+
// Entity definitions are NOT scanned. A `rig-hitbox.model` is an asset id, so
|
|
818
|
+
// there is no path in an entity.json left to find — and the declaration is the
|
|
819
|
+
// better list anyway, because it also holds models a game loads from its own
|
|
820
|
+
// code, which the entity scan never saw.
|
|
821
|
+
//
|
|
822
|
+
// Read as TEXT rather than imported, by the engine's own reader
|
|
823
|
+
// (`shared/ui/assets/declared.js`) — the same one the entity lint uses, so the
|
|
824
|
+
// two gates cannot disagree about what a game declares. Its blind spot is a
|
|
825
|
+
// computed `src`, which mirrors the one the entity scan had for a computed model
|
|
826
|
+
// reference.
|
|
827
|
+
|
|
828
|
+
// The reader is passed IN rather than imported: it lives in the engine tree
|
|
829
|
+
// (which the CLI resolves at run time — in a creator's repo it sits under
|
|
830
|
+
// node_modules), and handing it over keeps this function a pure filter over a
|
|
831
|
+
// parse somebody else did.
|
|
832
|
+
export function referencedModels(gameDir, readDeclaration) {
|
|
833
|
+
const manifest = join(gameDir, 'assets.js');
|
|
834
|
+
if (!existsSync(manifest) || typeof readDeclaration !== 'function') return [];
|
|
835
|
+
let declared;
|
|
836
|
+
try {
|
|
837
|
+
declared = readDeclaration(readFileSync(manifest, 'utf8'));
|
|
838
|
+
} catch {
|
|
839
|
+
return []; // unreadable assets.js fails its own gate
|
|
840
|
+
}
|
|
818
841
|
const found = new Set();
|
|
819
|
-
const
|
|
820
|
-
if (
|
|
821
|
-
|
|
822
|
-
const p = join(dir, e);
|
|
823
|
-
const st = statSync(p);
|
|
824
|
-
if (st.isDirectory()) { if (e !== 'node_modules') scan(p); continue; }
|
|
825
|
-
if (!/entity\.json$/.test(e)) continue;
|
|
826
|
-
const walk = (v) => {
|
|
827
|
-
if (typeof v === 'string') { if (/\.glb$/i.test(v)) found.add(resolve(gameDir, v)); }
|
|
828
|
-
else if (Array.isArray(v)) v.forEach(walk);
|
|
829
|
-
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
|
830
|
-
};
|
|
831
|
-
try { walk(JSON.parse(readFileSync(p, 'utf8'))); } catch { /* a broken entity.json fails its own gate */ }
|
|
832
|
-
}
|
|
833
|
-
};
|
|
834
|
-
scan(join(gameDir, 'entities'));
|
|
842
|
+
for (const src of declared.sources.values()) {
|
|
843
|
+
if (/\.glb$/i.test(src)) found.add(resolve(gameDir, src));
|
|
844
|
+
}
|
|
835
845
|
return [...found].filter(existsSync);
|
|
836
846
|
}
|
|
837
847
|
|
|
848
|
+
// The engine's declaration reader, or null when there is no engine to read with.
|
|
849
|
+
async function declarationReader(gameDir) {
|
|
850
|
+
try {
|
|
851
|
+
const { dir } = resolveEngine(gameDir);
|
|
852
|
+
const mod = await import(pathToFileURL(join(dir, 'shared', 'ui', 'assets', 'declared.js')).href);
|
|
853
|
+
return mod.readDeclaration;
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
838
859
|
export async function ensureBaked({ dir = process.cwd(), log = console.log } = {}) {
|
|
839
|
-
const models = referencedModels(dir);
|
|
860
|
+
const models = referencedModels(dir, await declarationReader(dir));
|
|
840
861
|
const baked = [];
|
|
841
862
|
for (const glb of models) {
|
|
842
863
|
if (!isStale(glb)) continue;
|
package/lib/project.mjs
CHANGED
|
@@ -32,7 +32,38 @@ export function findProject(startDir = process.cwd()) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// An engine CHECKOUT, used where it lies — the lane for working on the engine
|
|
36
|
+
// itself against a real game. Dev-only, and env-gated rather than a flag so it
|
|
37
|
+
// cannot be left switched on in a repo.
|
|
38
|
+
//
|
|
39
|
+
// Every tool resolves the engine through here, which is the point: the dev
|
|
40
|
+
// server serving one engine while `looop lint` and the auto-bake sweep check a
|
|
41
|
+
// different one is a false green of the worst kind — the browser runs the new
|
|
42
|
+
// code, the gate passes on the old, and nothing on screen says so.
|
|
43
|
+
//
|
|
44
|
+
// It does NOT fall back to the installed copy when the path is wrong. A silent
|
|
45
|
+
// fallback is how the override became invisible in the first place.
|
|
46
|
+
function engineCheckout(dir) {
|
|
47
|
+
if (!existsSync(join(dir, 'package.json')) || !existsSync(join(dir, 'shared'))) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`LOOOP_ENGINE_DIR=${dir} is not an engine build.\n` +
|
|
50
|
+
'Expected a package.json and a shared/ directory in it — point at an engine ' +
|
|
51
|
+
"bundle (packages/engine/dist after `node packages/engine/build.mjs`), not at the repo root.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
55
|
+
return {
|
|
56
|
+
dir,
|
|
57
|
+
// A checkout carries no cut version; only a release does.
|
|
58
|
+
version: pkg.version ?? 'working tree',
|
|
59
|
+
sharedDir: join(dir, 'shared'),
|
|
60
|
+
roomServerDir: join(dir, 'room-server'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
export function resolveEngine(projectDir) {
|
|
65
|
+
const override = process.env.LOOOP_ENGINE_DIR;
|
|
66
|
+
if (override) return engineCheckout(resolve(override));
|
|
36
67
|
const dir = join(projectDir, 'node_modules', '@looop-games', 'engine');
|
|
37
68
|
const pkgPath = join(dir, 'package.json');
|
|
38
69
|
if (!existsSync(pkgPath)) {
|
package/lib/publish.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// /api/publish stays behind the builder's Access wall). The endpoint
|
|
11
11
|
// requires the creator token; without one it 401s with a login hint.
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
13
14
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
15
|
import { join, relative } from 'node:path';
|
|
15
16
|
import { findProject } from './project.mjs';
|
|
@@ -32,6 +33,12 @@ export const PLAY_BASE = 'https://play.looop.games';
|
|
|
32
33
|
// Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
|
|
33
34
|
// instructions (repo knowledge travels with the repo, not the catalog — a
|
|
34
35
|
// published game must not expose it on a public URL), tests/smokes.
|
|
36
|
+
//
|
|
37
|
+
// This list is the floor, not the whole rule: whatever the game's own
|
|
38
|
+
// .gitignore excludes is dropped as well (gitIgnored below). Name-based lists
|
|
39
|
+
// can only ever cover the names somebody thought of, and the folder that
|
|
40
|
+
// prompted this was `screenshots/` — dev captures a test run had left behind,
|
|
41
|
+
// gitignored by the creator and published to their public URL regardless.
|
|
35
42
|
const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', '.looop', '.claude', '__pycache__']);
|
|
36
43
|
const SKIP_FILES = [
|
|
37
44
|
/^package(-lock)?\.json$/,
|
|
@@ -41,8 +48,38 @@ const SKIP_FILES = [
|
|
|
41
48
|
/^(AGENTS|CLAUDE|GEMINI)\.md$/,
|
|
42
49
|
];
|
|
43
50
|
|
|
51
|
+
// What the creator's own .gitignore excludes, out of a list of candidate paths.
|
|
52
|
+
//
|
|
53
|
+
// A gitignored path is the creator saying, in the file they already maintain for
|
|
54
|
+
// exactly this, that it is not part of the game — build output, scratch, and the
|
|
55
|
+
// screenshots a test run left behind. Publishing it puts it on a public URL
|
|
56
|
+
// under their name, which is nobody's intent.
|
|
57
|
+
//
|
|
58
|
+
// One `git check-ignore` call for the whole list rather than one per file, and
|
|
59
|
+
// NUL-delimited so a path with a space or a newline in it survives. A folder
|
|
60
|
+
// that is not a git repo (or a machine with no git) answers nothing and the
|
|
61
|
+
// list goes through untouched — this can only ever REMOVE files, so failing
|
|
62
|
+
// open is the safe direction.
|
|
63
|
+
export function gitIgnored(dir, rels) {
|
|
64
|
+
if (!rels.length) return new Set();
|
|
65
|
+
try {
|
|
66
|
+
const out = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
|
|
67
|
+
cwd: dir,
|
|
68
|
+
input: `${rels.join('\0')}\0`,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
71
|
+
});
|
|
72
|
+
return new Set(out.split('\0').filter(Boolean));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Exit 1 means "nothing on the list is ignored" and is not an error; git
|
|
75
|
+
// still wrote an empty stdout. Anything else (no git, not a repo) lands here
|
|
76
|
+
// too, and the answer is the same: exclude nothing.
|
|
77
|
+
return new Set((err?.stdout ?? '').split('\0').filter(Boolean));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
44
81
|
export function collectGameFiles(dir) {
|
|
45
|
-
const
|
|
82
|
+
const found = new Map(); // relative path → absolute path
|
|
46
83
|
const walk = (d) => {
|
|
47
84
|
for (const name of readdirSync(d)) {
|
|
48
85
|
const p = join(d, name);
|
|
@@ -52,11 +89,18 @@ export function collectGameFiles(dir) {
|
|
|
52
89
|
walk(p);
|
|
53
90
|
} else {
|
|
54
91
|
if (name.startsWith('.') || SKIP_FILES.some((re) => re.test(name))) continue;
|
|
55
|
-
|
|
92
|
+
found.set(rel, p);
|
|
56
93
|
}
|
|
57
94
|
}
|
|
58
95
|
};
|
|
59
96
|
walk(dir);
|
|
97
|
+
|
|
98
|
+
const ignored = gitIgnored(dir, [...found.keys()]);
|
|
99
|
+
const out = new Map(); // relative path → Buffer
|
|
100
|
+
for (const [rel, p] of found) {
|
|
101
|
+
if (ignored.has(rel)) continue;
|
|
102
|
+
out.set(rel, readFileSync(p));
|
|
103
|
+
}
|
|
60
104
|
return out;
|
|
61
105
|
}
|
|
62
106
|
|
|
@@ -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
|
+
}
|
package/lib/room-server.mjs
CHANGED
|
@@ -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
|
|
145
|
-
// no primitives of its own
|
|
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
|
-
|
|
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 };
|
package/lib/static-server.mjs
CHANGED
|
@@ -149,13 +149,19 @@ export function createStaticServer({
|
|
|
149
149
|
|
|
150
150
|
function serveHtml(res, fsPath, urlPath, query) {
|
|
151
151
|
let html = readFileSync(fsPath, 'utf8');
|
|
152
|
-
|
|
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).
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
//
|
|
157
|
+
// Only a GAME entry is injected, which is what production does: /g/<slug> is
|
|
158
|
+
// the only page that gets the platform layer there. Injecting it into every
|
|
159
|
+
// HTML file the dev server happens to serve puts the boot gate, the loading
|
|
160
|
+
// curtain and the M-key menu on top of pages that are not games and cannot
|
|
161
|
+
// satisfy them — the shared inspector page (/shared/ui/inspector/) renders
|
|
162
|
+
// its stage underneath a curtain waiting for an identity it never asked for.
|
|
163
|
+
const m = /^\/games\/([^/]+)\//.exec(urlPath);
|
|
164
|
+
if (m && resolveUrl(PLATFORM_URL)) {
|
|
159
165
|
// ?as=<name>: a distinct dev identity for this tab. The room dedups
|
|
160
166
|
// same-account connections even for unverified dev claims, so two tabs
|
|
161
167
|
// as the constant dev identity evict each other — ?as= is how a human
|
|
@@ -165,7 +171,7 @@ export function createStaticServer({
|
|
|
165
171
|
const tabIdentity = as
|
|
166
172
|
? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
|
|
167
173
|
: identity;
|
|
168
|
-
html = injectHeadTags(html, m
|
|
174
|
+
html = injectHeadTags(html, m[1], tabIdentity ? { identity: tabIdentity } : {});
|
|
169
175
|
}
|
|
170
176
|
if (injectReload) {
|
|
171
177
|
html = html.includes('</body>') ? html.replace('</body>', RELOAD_CLIENT + '</body>') : html + RELOAD_CLIENT;
|
|
@@ -173,11 +179,11 @@ export function createStaticServer({
|
|
|
173
179
|
sendBody(res, 200, html, MIME['.html']);
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
function serveJs(res, fsPath) {
|
|
182
|
+
function serveJs(res, fsPath, urlPath) {
|
|
177
183
|
const raw = readFileSync(fsPath);
|
|
178
184
|
let body;
|
|
179
185
|
try {
|
|
180
|
-
body = rewriteJsImports(raw.toString('utf8'), {
|
|
186
|
+
body = rewriteJsImports(raw.toString('utf8'), { baseUrl: urlPath, resolveUrl });
|
|
181
187
|
} catch {
|
|
182
188
|
body = raw;
|
|
183
189
|
}
|
|
@@ -244,7 +250,7 @@ export function createStaticServer({
|
|
|
244
250
|
const ext = extname(fsPath).toLowerCase();
|
|
245
251
|
try {
|
|
246
252
|
if (ext === '.html') return serveHtml(res, fsPath, urlPath, reqUrl.searchParams);
|
|
247
|
-
if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath);
|
|
253
|
+
if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath, urlPath);
|
|
248
254
|
sendBody(res, 200, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream');
|
|
249
255
|
} catch {
|
|
250
256
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|