@looop-games/cli 0.1.23 → 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 +37 -0
- package/lib/agent-surface.mjs +86 -39
- package/lib/dev.mjs +108 -12
- package/lib/inject.mjs +122 -21
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +16 -6
- package/lib/static-server.mjs +4 -5
- package/lib/update.mjs +26 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,43 @@ 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
|
+
|
|
38
|
+
## [0.1.24] - 2026-08-01
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `looop update` now always installs Looop's version of the skill files and
|
|
43
|
+
the `AGENTS.md` managed block, and removes retired ones — even where they
|
|
44
|
+
were changed locally. Before, an edited file was kept and silently stopped
|
|
45
|
+
receiving updates forever; one unnoticed agent edit could freeze a skill
|
|
46
|
+
out of every future release. Nothing is lost when this happens: the update
|
|
47
|
+
marks each such file `OVERWROTE`, saves the previous copy in
|
|
48
|
+
`.looop/backup-<version>/` (git has it too), and the `/update-looop` skill
|
|
49
|
+
walks you through keeping anything that mattered. Guidance you want kept
|
|
50
|
+
belongs in `AGENTS.md` below the managed block, where updates never touch
|
|
51
|
+
it. Repos already stuck with a frozen skill heal themselves on their next
|
|
52
|
+
update — no action needed.
|
|
53
|
+
|
|
17
54
|
## [0.1.23] - 2026-08-01
|
|
18
55
|
|
|
19
56
|
### Changed
|
package/lib/agent-surface.mjs
CHANGED
|
@@ -23,26 +23,41 @@
|
|
|
23
23
|
//
|
|
24
24
|
// The one contract everything here serves:
|
|
25
25
|
//
|
|
26
|
-
// an update
|
|
27
|
-
//
|
|
26
|
+
// an update always lands Looop's surface, and never loses the
|
|
27
|
+
// creator's content — anything it replaces or removes is backed up
|
|
28
|
+
// and reported.
|
|
28
29
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// Ownership is by PATH (note hx3kd9): a path the artifact ships belongs to the
|
|
31
|
+
// platform and always converges to the artifact's version. On each run a file at
|
|
32
|
+
// a shipped path is one of:
|
|
31
33
|
//
|
|
32
|
-
//
|
|
33
|
-
// ours,
|
|
34
|
-
//
|
|
35
|
-
// not on disk
|
|
36
|
-
// retired upstream
|
|
34
|
+
// content already equal to the incoming → heal the record, silently
|
|
35
|
+
// ours, unmodified (hash == recorded) → replace with the new
|
|
36
|
+
// anything else on disk → back it up, replace, report lossy
|
|
37
|
+
// not on disk → place ours
|
|
38
|
+
// retired upstream / orphaned → remove (backup + lossy when the
|
|
39
|
+
// content didn't match the record)
|
|
37
40
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// There is deliberately no keep-theirs outcome. The old model kept edited
|
|
42
|
+
// files and "shadowed" the incoming version — which permanently froze a file
|
|
43
|
+
// out of every future update after a single (usually agent-made, usually
|
|
44
|
+
// unnoticed) edit, breaking the harness for that repo silently (fb_3a0bea).
|
|
45
|
+
// The hash record in `.looop/agent-surface.json` is therefore not an ownership
|
|
46
|
+
// gate anymore: it is a change DETECTOR, kept so the report can say "this had
|
|
47
|
+
// local content" honestly instead of warning on every write. `lossy` is that
|
|
48
|
+
// report; `looop update` narrates it and the /update-looop skill asks the
|
|
49
|
+
// creator what to do with each lost edit.
|
|
50
|
+
//
|
|
51
|
+
// A skill we never ship is never even enumerated, so a creator's own skills
|
|
52
|
+
// are invisible to this code by construction. `AGENTS.md` is the sanctioned
|
|
53
|
+
// home for creator additions: the managed block between the markers is ours to
|
|
54
|
+
// rewrite; everything outside them (their title, their instructions below the
|
|
55
|
+
// end marker) is theirs and is spliced back untouched — and when the markers
|
|
56
|
+
// themselves have been deleted, the block is re-appended below their content
|
|
57
|
+
// rather than contesting any of it.
|
|
43
58
|
import { createHash } from 'node:crypto';
|
|
44
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
-
import { dirname, join } from 'node:path';
|
|
59
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
60
|
+
import { dirname, join, relative } from 'node:path';
|
|
46
61
|
|
|
47
62
|
export const SURFACE_DIR = 'agent-surface';
|
|
48
63
|
export const LOCAL_MANIFEST = '.looop/agent-surface.json';
|
|
@@ -100,9 +115,27 @@ function pruneEmptyDirs(projectDir, dest) {
|
|
|
100
115
|
}
|
|
101
116
|
}
|
|
102
117
|
|
|
118
|
+
// Whether everything on disk at `dest` (a file, or a whole skill folder) still
|
|
119
|
+
// matches the hashes recorded for it. Distinguishes a clean retire/orphan
|
|
120
|
+
// removal from one that destroys real local content — only the latter earns a
|
|
121
|
+
// backup and a `lossy` report. A file with no record at all counts as
|
|
122
|
+
// non-matching: content we can't vouch for is content to preserve.
|
|
123
|
+
function contentMatchesRecord(abs, dest, placed) {
|
|
124
|
+
if (!statSync(abs).isDirectory()) {
|
|
125
|
+
return placed[dest] !== undefined && sha(readFileSync(abs)) === placed[dest];
|
|
126
|
+
}
|
|
127
|
+
const entries = readdirSync(abs, { recursive: true, withFileTypes: true }).filter((e) => e.isFile());
|
|
128
|
+
if (!entries.length) return true; // an empty folder holds nothing to lose
|
|
129
|
+
return entries.every((e) => {
|
|
130
|
+
const file = join(e.parentPath, e.name);
|
|
131
|
+
const rel = join(dest, relative(abs, file));
|
|
132
|
+
return placed[rel] !== undefined && sha(readFileSync(file)) === placed[rel];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
// Splice the artifact's managed block into the repo's AGENTS.md, preserving
|
|
104
137
|
// everything outside the markers. Returns null when the creator's file has no
|
|
105
|
-
// markers
|
|
138
|
+
// markers; the caller then re-appends the block below their content.
|
|
106
139
|
function spliceManaged(current, incoming) {
|
|
107
140
|
const cs = current.indexOf(START);
|
|
108
141
|
const ce = current.indexOf(END);
|
|
@@ -122,7 +155,7 @@ function spliceManaged(current, incoming) {
|
|
|
122
155
|
*/
|
|
123
156
|
export function reconcileAgentSurface(projectDir, engineDir, { log = console.log } = {}) {
|
|
124
157
|
const result = {
|
|
125
|
-
placed: [], removed: [],
|
|
158
|
+
placed: [], removed: [], lossy: [], backups: [],
|
|
126
159
|
adopted: false, skipped: false, engineVersion: null, backupDir: null,
|
|
127
160
|
};
|
|
128
161
|
const surface = join(engineDir, SURFACE_DIR);
|
|
@@ -166,19 +199,22 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
|
|
|
166
199
|
|
|
167
200
|
// ── retired entries: remove what upstream dropped. This is the half that
|
|
168
201
|
// makes a RENAME possible on a repo that already exists — without it a
|
|
169
|
-
// replaced skill would linger next to its replacement forever
|
|
202
|
+
// replaced skill would linger next to its replacement forever, still
|
|
203
|
+
// loaded by the creator's agent as live guidance. Removal is
|
|
204
|
+
// unconditional (the path is Looop's); content that doesn't match
|
|
205
|
+
// the record is preserved in the backup and reported lossy. ───────────
|
|
170
206
|
for (const rel of manifest.retired ?? []) {
|
|
171
207
|
const dest = target(rel);
|
|
172
208
|
if (!dest) continue;
|
|
173
209
|
const abs = join(projectDir, dest);
|
|
174
210
|
if (!existsSync(abs)) continue;
|
|
175
211
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
212
|
+
if (adopt) {
|
|
213
|
+
backup(dest); // no record to compare — preserve, but don't accuse
|
|
214
|
+
} else if (!contentMatchesRecord(abs, dest, placed)) {
|
|
215
|
+
backup(dest);
|
|
216
|
+
result.lossy.push(dest);
|
|
180
217
|
}
|
|
181
|
-
if (adopt) backup(dest);
|
|
182
218
|
rmSync(abs, { recursive: true, force: true });
|
|
183
219
|
for (const p of Object.keys(placed)) {
|
|
184
220
|
if (p === dest || p.startsWith(`${dest}/`)) delete placed[p];
|
|
@@ -201,12 +237,16 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
|
|
|
201
237
|
|
|
202
238
|
if (dest === 'AGENTS.md') {
|
|
203
239
|
// The markers ARE the contract: inside is ours, outside is theirs.
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
: desired.toString('utf8');
|
|
240
|
+
const desiredStr = desired.toString('utf8');
|
|
241
|
+
let next = exists ? spliceManaged(current.toString('utf8'), desiredStr) : desiredStr;
|
|
207
242
|
if (next === null) {
|
|
208
|
-
|
|
209
|
-
|
|
243
|
+
// Their file has no markers — they (or their agent) rewrote Layer 0.
|
|
244
|
+
// The block must still land, but everything they wrote is theirs, so
|
|
245
|
+
// it is re-appended below their content, touching none of it.
|
|
246
|
+
const is = desiredStr.indexOf(START);
|
|
247
|
+
const ie = desiredStr.indexOf(END);
|
|
248
|
+
if (is === -1 || ie === -1) continue; // artifact itself has no block — nothing to land
|
|
249
|
+
next = current.toString('utf8').replace(/\s*$/, '\n\n') + desiredStr.slice(is, ie + END.length) + '\n';
|
|
210
250
|
}
|
|
211
251
|
if (exists && current.toString('utf8') === next) continue;
|
|
212
252
|
writeFileSync(abs, next);
|
|
@@ -217,16 +257,21 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
|
|
|
217
257
|
|
|
218
258
|
if (exists) {
|
|
219
259
|
const now = sha(current);
|
|
220
|
-
if (now ===
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Either they edited ours, or it was never ours. Both are theirs.
|
|
226
|
-
result.shadowed.push(dest);
|
|
227
|
-
placed[dest] = recorded ?? now;
|
|
260
|
+
if (now === sha(desired)) {
|
|
261
|
+
// Content already equal — including the frozen-record case where a
|
|
262
|
+
// local edit was independently upstreamed (fb_3a0bea). Nothing to
|
|
263
|
+
// write, nothing lost; heal the record so future updates apply.
|
|
264
|
+
placed[dest] = now;
|
|
228
265
|
continue;
|
|
229
266
|
}
|
|
267
|
+
if (adopt) {
|
|
268
|
+
backup(dest); // no record to compare — preserve, but don't accuse
|
|
269
|
+
} else if (now !== recorded) {
|
|
270
|
+
// Edited ours, or never ours — either way real local content is
|
|
271
|
+
// about to be replaced: preserve it and report it for the ask flow.
|
|
272
|
+
backup(dest);
|
|
273
|
+
result.lossy.push(dest);
|
|
274
|
+
}
|
|
230
275
|
}
|
|
231
276
|
|
|
232
277
|
mkdirSync(dirname(abs), { recursive: true });
|
|
@@ -251,8 +296,10 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
|
|
|
251
296
|
const abs = join(projectDir, dest);
|
|
252
297
|
if (existsSync(abs)) {
|
|
253
298
|
if (sha(readFileSync(abs)) !== placed[dest]) {
|
|
254
|
-
|
|
255
|
-
|
|
299
|
+
// They changed it — the retirement still lands, the content survives
|
|
300
|
+
// in the backup and the caller asks the creator about it.
|
|
301
|
+
backup(dest);
|
|
302
|
+
result.lossy.push(dest);
|
|
256
303
|
}
|
|
257
304
|
rmSync(abs, { force: true });
|
|
258
305
|
pruneEmptyDirs(projectDir, dest);
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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=<
|
|
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
|
}
|
|
@@ -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,8 +149,7 @@ 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).
|
|
@@ -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'), {
|
|
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/lib/update.mjs
CHANGED
|
@@ -22,19 +22,39 @@ import { fetchStatus, landedIn } from './feedback-sync.mjs';
|
|
|
22
22
|
// The report is the point. A creator reading this must be able to answer, with
|
|
23
23
|
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
24
24
|
function report(log, engineVersion, surface) {
|
|
25
|
-
const { placed = [], removed = [],
|
|
26
|
-
if (!placed.length && !removed.length && !
|
|
25
|
+
const { placed = [], removed = [], lossy = [], backups = [], backupDir, adopted } = surface;
|
|
26
|
+
if (!placed.length && !removed.length && !lossy.length) {
|
|
27
27
|
log(' Your skills and instructions were already current.');
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
log('');
|
|
32
32
|
log(`Looop also updated this game's skills and instructions to match engine ${engineVersion}:`);
|
|
33
|
-
for (const f of placed)
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
for (const f of placed) {
|
|
34
|
+
if (lossy.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
|
|
35
|
+
else log(` updated ${f}`);
|
|
36
|
+
}
|
|
37
|
+
for (const f of removed) {
|
|
38
|
+
if (lossy.includes(f)) log(` removed ${f} (retired by Looop — it had local changes, see below)`);
|
|
39
|
+
else log(` removed ${f} (retired by Looop)`);
|
|
40
|
+
}
|
|
41
|
+
for (const f of lossy) {
|
|
42
|
+
if (!placed.includes(f) && !removed.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (lossy.length) {
|
|
46
|
+
// The overwrite is the contract (Looop's files always land — a kept edit
|
|
47
|
+
// used to freeze the file out of every future update), but the content is
|
|
48
|
+
// never gone: name both recovery routes and the one place local guidance
|
|
49
|
+
// is safe from updates.
|
|
50
|
+
log('');
|
|
51
|
+
log(' Files marked OVERWROTE had local changes that Looop replaced. Nothing is');
|
|
52
|
+
log(` lost: recover them with \`git diff\`${backupDir ? `, or from ${backupDir}` : ''}.`);
|
|
53
|
+
log(' Guidance you want to keep belongs in AGENTS.md, below the managed block —');
|
|
54
|
+
log(' updates never touch anything there.');
|
|
55
|
+
}
|
|
36
56
|
|
|
37
|
-
if (backups.length) {
|
|
57
|
+
if (adopted && backups.length) {
|
|
38
58
|
// On adoption we cannot tell an untouched old platform file from one the
|
|
39
59
|
// creator edited — there is no ownership record to compare against, which
|
|
40
60
|
// is precisely why we're adopting. Most of these are just the previous
|