@looop-games/cli 0.1.34 → 0.1.36

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.
@@ -12,8 +12,31 @@ import http from 'node:http';
12
12
  import { EventEmitter } from 'node:events';
13
13
  import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
14
14
  import { extname, join, normalize, sep } from 'node:path';
15
+ import { gunzipSync } from 'node:zlib';
15
16
  import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL, TOOLBOX_URL } from './inject.mjs';
16
17
  import { WHOAMI_PATH } from './ports.mjs';
18
+ import {
19
+ writeSegment, stitchStream, streamDir, listRecordings, latestRecording, deleteRecording, setKept,
20
+ DEV_SEGMENT_PATH, DEV_REPLAYS_PATH,
21
+ } from './replay-store.mjs';
22
+
23
+ // `?replay=<id>` resolves to `.looop/replays/<id>.json` (replaySourceUrl in the
24
+ // engine's replay transport). Nothing writes that FILE — the room writes the
25
+ // stream's segments in a directory beside it — so the dev server answers the
26
+ // path by stitching the directory. That is what makes watching a session back
27
+ // a paste-the-URL operation with no fetch step in front of it.
28
+ // Anchored at the game's own mount rather than "anywhere a path happens to end
29
+ // this way": the handler ignores the prefix, so a loose pattern would serve the
30
+ // project's recordings from any URL that ended in one.
31
+ const REPLAY_URL_RE = /^\/games\/[^/]+\/\.looop\/replays\/([a-z0-9][a-z0-9-]{0,63})\.json$/;
32
+
33
+ // The one stream id nobody owns. `?replay=last` is what makes watching a
34
+ // session back typeable by a human — the alternative is copying a base36
35
+ // timestamp off a console line, on the phone that just played. It can never
36
+ // collide with a real recording: stream ids are minted as
37
+ // `<base36 ms>-<base36 counter>` (shared/ui/room/entity/recorder.js), so every
38
+ // one of them carries a dash and none of them is a word.
39
+ const LATEST_ALIAS = 'last';
17
40
 
18
41
  const RELOAD_CLIENT = `<script>
19
42
  (() => {
@@ -39,6 +62,75 @@ const WATCH_EXTS = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.p
39
62
  // payload from filling the disk — reports are small JSON summaries.
40
63
  export const AGENT_INBOX_PATH = '/__looop/agent-inbox';
41
64
  const AGENT_INBOX_MAX_BYTES = 2 * 1024 * 1024;
65
+ // What a segment may weigh ON THE WIRE. The room gzips anything big, and a
66
+ // keyframe segment always is — a segment's size is the size of the world it
67
+ // snapshots, which no room-side event cap can bound — so this is a bound on
68
+ // COMPRESSED bytes, where a real game's 1.13 MB keyframe arrives as ~60 KB.
69
+ const SEGMENT_MAX_BYTES = 1024 * 1024;
70
+ // And what it may weigh once inflated. The compressed bound says nothing about
71
+ // this: a few hundred KB of repeated bytes expands to gigabytes, and with the
72
+ // endpoint unauthenticated by design the expansion ratio is the sender's to
73
+ // choose. Sized well above the largest plausible keyframe (a 356-entity world
74
+ // is 1.13 MB) so that only an attack, never a big game, meets it.
75
+ const SEGMENT_MAX_INFLATED_BYTES = 32 * 1024 * 1024;
76
+ // What the `keep` toggle may weigh: it is one boolean.
77
+ const KEEP_MAX_BYTES = 1024;
78
+
79
+ // The name this server was reached by, and whether that name could have been
80
+ // pointed here by somebody else.
81
+ //
82
+ // A literal IP address and `localhost` cannot be aimed at this machine by a
83
+ // stranger — they are not resolved by anyone's DNS. A registrable HOSTNAME can:
84
+ // the attacker publishes `evil.example A 127.0.0.1` (or rebinds it after the
85
+ // page loads), the creator visits it, and the browser now considers the dev
86
+ // server same-origin — Origin and Host agree because the attacker chose both.
87
+ // Requiring the host to be a literal is what makes the check below compare
88
+ // something an attacker does not control.
89
+ //
90
+ // The phone case survives unchanged: a phone on the wifi reaches this server at
91
+ // `http://192.168.x.y:<port>`, which is a literal.
92
+ function trustedHost(header) {
93
+ const host = String(header ?? '').replace(/:\d+$/, '').toLowerCase();
94
+ if (host === 'localhost') return true;
95
+ if (host.startsWith('[') && host.endsWith(']')) return true; // literal IPv6
96
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // literal IPv4
97
+ if (host.endsWith('.local')) return true; // mDNS — resolved on the wire, not by DNS
98
+ return false;
99
+ }
100
+
101
+ // Whether a request came from a page this dev server itself serves.
102
+ //
103
+ // The dev server binds every interface — it prints a LAN URL so a phone can
104
+ // play — so "it is localhost" is not a trust boundary: any device on the same
105
+ // wifi can reach it, and a hostile page in the creator's own browser can fire a
106
+ // no-preflight POST at it (a CORS "simple" content-type skips the preflight,
107
+ // and CORS gates reading a response, never the server-side write). A browser
108
+ // stamps Origin on anything cross-origin; a non-browser caller sends none and
109
+ // passes, at the same trust level as anything else already running on the
110
+ // machine.
111
+ //
112
+ // Two conditions, and the second is not redundant: an Origin that MATCHES the
113
+ // Host proves nothing on its own, because a hostname aimed at this machine
114
+ // gives an attacker both halves at once (see trustedHost).
115
+ //
116
+ // `requireOrigin` turns the absent-header case from pass into fail, and is what
117
+ // separates "a page this server served" from "any program that can reach this
118
+ // port". A browser attaches Origin to every request whose method is not GET or
119
+ // HEAD, same-origin ones included — so on the MUTATING verbs the header is free
120
+ // to demand and refuses every non-browser caller. A GET cannot demand it: a
121
+ // same-origin GET carries no Origin either, so the game page fetching its own
122
+ // recording would be refused along with everything else.
123
+ //
124
+ // It is deliberately NOT applied to segment ingest. A multiplayer room posts
125
+ // its segments from a partykit worker, which is not a served page and has no
126
+ // Origin to send; requiring one there would silently stop multiplayer sessions
127
+ // being recorded at all.
128
+ function sameOrigin(req, { requireOrigin = false } = {}) {
129
+ if (!trustedHost(req.headers.host)) return false;
130
+ const origin = req.headers.origin;
131
+ if (!origin) return !requireOrigin;
132
+ return origin === `http://${req.headers.host}`;
133
+ }
42
134
 
43
135
  const MIME = {
44
136
  '.html': 'text/html; charset=utf-8',
@@ -113,6 +205,11 @@ export function createStaticServer({
113
205
  // import map + globalThis.__LOOOP_SKELETON__ so startGame() can boot. Null for
114
206
  // a v1 game (no bare `looop` import, no skeleton).
115
207
  skeleton = null,
208
+ // The engine version this server serves, stamped onto every agent-inbox
209
+ // file as `engine`. A report is read off disk later against whatever the
210
+ // game is pinned to THEN; the server is the only side that knows what it
211
+ // was when the report was made (the page has no version of its own).
212
+ engineVersion = null,
116
213
  }) {
117
214
  // The injected skeleton is LIVE, not frozen at startup: a v2 hot-reload
118
215
  // rebuilds the room with a fresh skeleton, and `setSkeleton` below pushes that
@@ -139,6 +236,16 @@ export function createStaticServer({
139
236
  } catch {
140
237
  return null;
141
238
  }
239
+ // `.looop/` is the game's private working directory, not part of the site.
240
+ // It holds the agent inbox (reports an agent reads as trusted) and the
241
+ // recorded sessions (which carry every player's entity seed, every
242
+ // non-replicated field, and every participant's account id — strictly more
243
+ // than any single live client is ever sent). This server binds every
244
+ // interface and stamps `Access-Control-Allow-Origin: *`, so serving that
245
+ // tree would hand it to any page in the creator's browser and to anything
246
+ // on the same wifi. Recordings are readable, but only through the stitched
247
+ // route below, which checks the origin.
248
+ if (/(?:^|\/)\.looop(?:\/|$)/.test(decoded)) return null;
142
249
  for (const { url, dir } of table) {
143
250
  if (!decoded.startsWith(url)) continue;
144
251
  const rest = decoded.slice(url.length).replace(/^\/+/, '');
@@ -224,6 +331,223 @@ export function createStaticServer({
224
331
  res.end(buf);
225
332
  }
226
333
 
334
+ // The engine bundle's shared/ tree — the LAST mount at /shared/, because the
335
+ // game's overrides dir shadows ahead of it. Stitching a recording goes
336
+ // through that engine's own reassembler rather than a copy here, so a
337
+ // recording is always read back by the engine that wrote it.
338
+ const engineSharedDir = table.filter((m) => m.url === '/shared/').at(-1)?.dir ?? null;
339
+
340
+ // A posted segment's JSON, inflating it when the room sent it gzipped.
341
+ //
342
+ // Two ways to know it is gzipped, because two senders reach here and only one
343
+ // of them can set headers: a normal flush is a fetch and says
344
+ // `content-encoding: gzip`, while the tail a page ships on `pagehide` may go
345
+ // out as a beacon, which carries a body and no headers of its own. So the
346
+ // header is honoured when present and the gzip magic number is sniffed when
347
+ // it is not. A body that CLAIMS gzip and is not gzip is an error rather than
348
+ // a fallback — silently accepting it would mean the two ends disagree about
349
+ // the wire format and neither ever finds out.
350
+ function readSegmentBody(req, buf) {
351
+ const declared = String(req.headers['content-encoding'] ?? '').toLowerCase().includes('gzip');
352
+ const sniffed = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b;
353
+ if (!declared && !sniffed) return buf.toString('utf8');
354
+ try {
355
+ return gunzipSync(buf, { maxOutputLength: SEGMENT_MAX_INFLATED_BYTES }).toString('utf8');
356
+ } catch (e) {
357
+ throw new Error(`segment could not be decompressed: ${e?.message ?? e}`);
358
+ }
359
+ }
360
+
361
+ async function receiveSegment(req, res) {
362
+ // Only the page (or room) this server itself serves may write, and only up
363
+ // to a bounded size — the same two defences the agent inbox above carries,
364
+ // for the same two reasons. The dev server binds every interface (it prints
365
+ // a LAN URL for phones), so "it is localhost" is not a boundary: a hostile
366
+ // website in the creator's browser can fire a no-preflight POST at it (a
367
+ // CORS "simple" content-type skips the preflight, and CORS gates reading
368
+ // the response, never the server-side write), and any device on the same
369
+ // wifi can just send one. Both would otherwise author files inside the
370
+ // creator's game folder, without limit.
371
+ if (!sameOrigin(req)) {
372
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
373
+ return res.end('cross-origin post rejected');
374
+ }
375
+ const chunks = [];
376
+ let size = 0;
377
+ for await (const c of req) {
378
+ size += c.length;
379
+ if (size > SEGMENT_MAX_BYTES) {
380
+ res.writeHead(413, { 'Content-Type': 'text/plain', Connection: 'close' });
381
+ return res.end(`segment too large (max ${SEGMENT_MAX_BYTES} bytes)`);
382
+ }
383
+ chunks.push(c);
384
+ }
385
+ try {
386
+ const out = writeSegment(projectDir, JSON.parse(readSegmentBody(req, Buffer.concat(chunks))));
387
+ return sendBody(res, 200, JSON.stringify(out), 'application/json');
388
+ } catch (e) {
389
+ // A rejected segment must name its reason: the room logs this line, and
390
+ // "the recording is missing" with no cause is the expensive version.
391
+ return sendBody(res, 400, JSON.stringify({ error: String(e?.message ?? e) }), 'application/json');
392
+ }
393
+ }
394
+
395
+ const wholeFilePath = (stream) => join(projectDir, '.looop', 'replays', `${stream}.json`);
396
+
397
+ async function serveReplay(res, requested) {
398
+ // `last` is resolved here rather than in the browser because only this side
399
+ // knows what is on disk — the transport turns a bare id into this path and
400
+ // has no directory to consult.
401
+ let stream = requested;
402
+ // Resolved AFTER the whole-file lane below has had its chance: a recording
403
+ // pulled in from elsewhere is served as a file, and `last.json` is a
404
+ // plausible name for a downloaded one. The alias must not shadow a file
405
+ // somebody deliberately put there.
406
+ if (requested === LATEST_ALIAS && !existsSync(wholeFilePath(requested))) {
407
+ stream = latestRecording(projectDir);
408
+ if (!stream) {
409
+ return sendBody(
410
+ res, 404,
411
+ JSON.stringify({ error: 'no recordings yet in this game — play a session under `looop dev` first, then ?replay=last watches it back' }),
412
+ 'application/json',
413
+ );
414
+ }
415
+ }
416
+ // The transport shows whatever comes back verbatim, so the two ways this
417
+ // fails have to be told apart. "There is no such session here" sends the
418
+ // reader to the machine that played it; a stitcher that refuses (a pinned
419
+ // engine older than recordings) is a different problem entirely, and
420
+ // reporting it as a missing recording would have them hunting for files
421
+ // that are sitting right there.
422
+ // A recording that arrived as a whole FILE (one pulled from elsewhere, rather
423
+ // than one this machine recorded) wins over the segments beside it. Read
424
+ // here rather than served from the static mount, because that mount does
425
+ // not serve `.looop/` at all — see resolveUrl.
426
+ const asFile = wholeFilePath(stream);
427
+ if (existsSync(asFile)) return sendBody(res, 200, readFileSync(asFile), 'application/json');
428
+ let body = null;
429
+ try {
430
+ body = engineSharedDir ? await stitchStream(projectDir, stream, engineSharedDir) : null;
431
+ } catch (e) {
432
+ return sendBody(res, 404, JSON.stringify({ error: String(e?.message ?? e) }), 'application/json');
433
+ }
434
+ if (!body) {
435
+ // Two different answers, and sending the wrong one is expensive. A stream
436
+ // that is HERE but has not cut its first keyframe yet is a not-yet: the
437
+ // files are on this disk, in front of the person reading. Telling them to
438
+ // go and find the machine that played it would send them somewhere else
439
+ // entirely, for a recording that will be watchable in a few seconds.
440
+ const here = existsSync(join(projectDir, '.looop', 'replays', stream));
441
+ return sendBody(
442
+ res,
443
+ 404,
444
+ JSON.stringify({
445
+ error: here
446
+ ? `session ${stream} has not been written far enough to watch yet — a recording becomes watchable a few seconds in, so give it a moment and reload`
447
+ : `no recording for session ${stream} in this game's .looop/replays/ — it is recorded on the machine that played it`,
448
+ }),
449
+ 'application/json',
450
+ );
451
+ }
452
+ return sendBody(res, 200, body, 'application/json');
453
+ }
454
+
455
+ // ── the sessions lane: what recordings exist, and what to do with one ─────
456
+ //
457
+ // The person who just played is holding a browser, and on a phone that is the
458
+ // only surface there is — so the list of their own recordings has to be
459
+ // answerable to the page. It rides the same `/__looop/` lane the segments
460
+ // arrive on rather than a mount of its own, because `.looop/` is deliberately
461
+ // not served as files (see resolveUrl) and must not start being.
462
+ //
463
+ // GET /__looop/replays every recording, newest first
464
+ // DELETE /__looop/replays/<stream> remove one, whole
465
+ // POST /__looop/replays/<stream>/keep { keep: bool } — pin against the prune
466
+ async function handleReplaysApi(req, res, urlPath) {
467
+ // Deleting a recording and pinning one are destructive and permanent; the
468
+ // list is a read. So the mutating verbs demand the Origin a browser always
469
+ // sends, and the read does not — see sameOrigin for why a GET cannot.
470
+ const mutating = req.method !== 'GET' && req.method !== 'HEAD';
471
+ if (!sameOrigin(req, { requireOrigin: mutating })) {
472
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
473
+ return res.end(mutating
474
+ ? 'this endpoint is only for the page this dev server serves'
475
+ : 'cross-origin request rejected');
476
+ }
477
+ const rest = urlPath.slice(DEV_REPLAYS_PATH.length);
478
+ // Deliberately NOT sendBody: that stamps `Access-Control-Allow-Origin: *`
479
+ // for the static mounts, and on these bodies it would mean the origin check
480
+ // above is the only thing between a cross-origin page and a creator's
481
+ // session list — no second layer at all.
482
+ const json = (status, body) => {
483
+ const buf = Buffer.from(JSON.stringify(body));
484
+ res.writeHead(status, {
485
+ 'Content-Type': 'application/json',
486
+ 'Content-Length': buf.length,
487
+ 'Cache-Control': 'no-store, must-revalidate',
488
+ });
489
+ res.end(buf);
490
+ };
491
+
492
+ if (rest === '' || rest === '/') {
493
+ if (req.method !== 'GET') {
494
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
495
+ return res.end('405');
496
+ }
497
+ return json(200, { recordings: listRecordings(projectDir) });
498
+ }
499
+
500
+ const m = /^\/([^/]+)(\/keep)?$/.exec(rest);
501
+ if (!m) {
502
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
503
+ return res.end('404');
504
+ }
505
+ // The id came off a URL and is about to become a path. `streamDir` is the
506
+ // one place that decides what a stream id may look like, and it refuses
507
+ // rather than sanitizes — so a bad id is a 400 with its reason, not a
508
+ // plausible directory somewhere else.
509
+ let stream;
510
+ try {
511
+ stream = decodeURIComponent(m[1]);
512
+ streamDir(projectDir, stream);
513
+ } catch (e) {
514
+ return json(400, { error: String(e?.message ?? e) });
515
+ }
516
+
517
+ if (m[2]) {
518
+ if (req.method !== 'POST') {
519
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
520
+ return res.end('405');
521
+ }
522
+ const chunks = [];
523
+ let size = 0;
524
+ for await (const c of req) {
525
+ size += c.length;
526
+ if (size > KEEP_MAX_BYTES) {
527
+ res.writeHead(413, { 'Content-Type': 'text/plain', Connection: 'close' });
528
+ return res.end('body too large');
529
+ }
530
+ chunks.push(c);
531
+ }
532
+ let keep;
533
+ try {
534
+ keep = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.keep === true;
535
+ } catch {
536
+ return json(400, { error: 'expected a JSON body of { keep: true } or { keep: false }' });
537
+ }
538
+ if (!setKept(projectDir, stream, keep)) return json(404, { error: `no recording ${stream}` });
539
+ return json(200, { stream, kept: keep });
540
+ }
541
+
542
+ if (req.method !== 'DELETE') {
543
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
544
+ return res.end('405');
545
+ }
546
+ if (!deleteRecording(projectDir, stream)) return json(404, { error: `no recording ${stream}` });
547
+ log(`replays: deleted ${stream}`);
548
+ return json(200, { stream, deleted: true });
549
+ }
550
+
227
551
  function handleSse(res) {
228
552
  res.writeHead(200, {
229
553
  'Content-Type': 'text/event-stream',
@@ -264,8 +588,7 @@ export function createStaticServer({
264
588
  // client is served from this server, so its Origin equals our host.
265
589
  // Non-browser callers (curl, node) send no Origin and pass — same trust
266
590
  // level as anything else already running on the creator's machine/LAN.
267
- const origin = req.headers.origin;
268
- if (origin && origin !== `http://${req.headers.host}`) {
591
+ if (!sameOrigin(req)) {
269
592
  res.writeHead(403, { 'Content-Type': 'text/plain' });
270
593
  return res.end('cross-origin post rejected');
271
594
  }
@@ -304,6 +627,7 @@ export function createStaticServer({
304
627
  mkdirSync(inboxDir, { recursive: true });
305
628
  const selfIgnore = join(inboxDir, '.gitignore');
306
629
  if (!existsSync(selfIgnore)) writeFileSync(selfIgnore, '*\n');
630
+ if (engineVersion) envelope.engine = engineVersion;
307
631
  const d = new Date();
308
632
  const p = (n, w = 2) => String(n).padStart(w, '0');
309
633
  const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}`;
@@ -338,6 +662,49 @@ export function createStaticServer({
338
662
  'application/json',
339
663
  );
340
664
  }
665
+ // The dev recording lane. A room posts its closed segments here and they
666
+ // land in the game's .looop/replays/ — no auth, because in dev the room,
667
+ // the folder and the player are the same machine and nothing crosses a
668
+ // trust boundary. A published room has no ingest lane at all yet (see
669
+ // sessionIngestUrlFor) and never reaches this.
670
+ if (urlPath === DEV_SEGMENT_PATH) {
671
+ if (!projectDir) {
672
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
673
+ return res.end('404');
674
+ }
675
+ if (req.method !== 'POST') {
676
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
677
+ return res.end('405');
678
+ }
679
+ return receiveSegment(req, res);
680
+ }
681
+ if (projectDir && (urlPath === DEV_REPLAYS_PATH || urlPath.startsWith(`${DEV_REPLAYS_PATH}/`))) {
682
+ // Caught here because this handler is async and its promise is returned,
683
+ // not awaited: an unhandled rejection inside it takes the whole dev server
684
+ // down, with the creator's game on it. The panel polls this route every
685
+ // few seconds while a prune, or a delete from another tab, can be removing
686
+ // the very directories it is walking — so a transient ENOENT is a normal
687
+ // event on this lane, not an exceptional one.
688
+ return handleReplaysApi(req, res, urlPath).catch((e) => {
689
+ log(`replays: ${e?.message ?? e}`);
690
+ if (res.headersSent) return res.end();
691
+ return sendBody(res, 500, JSON.stringify({ error: String(e?.message ?? e) }), 'application/json');
692
+ });
693
+ }
694
+ const replayMatch = projectDir && req.method === 'GET' && REPLAY_URL_RE.exec(urlPath);
695
+ if (replayMatch) {
696
+ // Same-origin only, for what it carries (see the `.looop/` note in
697
+ // resolveUrl). A browser sends Origin on a cross-origin GET of a
698
+ // fetch/XHR; the game page fetching its own recording does not, or sends
699
+ // ours. Non-browser callers pass, at the same trust level as anything
700
+ // else already running on the machine.
701
+ if (!sameOrigin(req)) {
702
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
703
+ return res.end('cross-origin read rejected');
704
+ }
705
+ return serveReplay(res, replayMatch[1]);
706
+ }
707
+
341
708
  if (urlPath === '/' || urlPath === '/index.html') {
342
709
  res.writeHead(302, { Location: gameEntry });
343
710
  return res.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,6 +22,7 @@
22
22
  "dependencies": {
23
23
  "cross-spawn": "^7.0.6",
24
24
  "esbuild": "^0.28.0",
25
+ "hyparquet-writer": "^0.16.9",
25
26
  "partykit": "^0.0.115"
26
27
  }
27
28
  }