@looop-games/cli 0.1.35 → 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.
- package/CHANGELOG.md +11 -0
- package/bin/looop.mjs +18 -0
- package/lib/dev.mjs +6 -0
- package/lib/replay-cmd.mjs +155 -0
- package/lib/replay-export.mjs +515 -0
- package/lib/replay-store.mjs +440 -0
- package/lib/room-server.mjs +47 -6
- package/lib/static-server.mjs +363 -2
- package/package.json +2 -1
package/lib/static-server.mjs
CHANGED
|
@@ -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',
|
|
@@ -144,6 +236,16 @@ export function createStaticServer({
|
|
|
144
236
|
} catch {
|
|
145
237
|
return null;
|
|
146
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;
|
|
147
249
|
for (const { url, dir } of table) {
|
|
148
250
|
if (!decoded.startsWith(url)) continue;
|
|
149
251
|
const rest = decoded.slice(url.length).replace(/^\/+/, '');
|
|
@@ -229,6 +331,223 @@ export function createStaticServer({
|
|
|
229
331
|
res.end(buf);
|
|
230
332
|
}
|
|
231
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
|
+
|
|
232
551
|
function handleSse(res) {
|
|
233
552
|
res.writeHead(200, {
|
|
234
553
|
'Content-Type': 'text/event-stream',
|
|
@@ -269,8 +588,7 @@ export function createStaticServer({
|
|
|
269
588
|
// client is served from this server, so its Origin equals our host.
|
|
270
589
|
// Non-browser callers (curl, node) send no Origin and pass — same trust
|
|
271
590
|
// level as anything else already running on the creator's machine/LAN.
|
|
272
|
-
|
|
273
|
-
if (origin && origin !== `http://${req.headers.host}`) {
|
|
591
|
+
if (!sameOrigin(req)) {
|
|
274
592
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
275
593
|
return res.end('cross-origin post rejected');
|
|
276
594
|
}
|
|
@@ -344,6 +662,49 @@ export function createStaticServer({
|
|
|
344
662
|
'application/json',
|
|
345
663
|
);
|
|
346
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
|
+
|
|
347
708
|
if (urlPath === '/' || urlPath === '/index.html') {
|
|
348
709
|
res.writeHead(302, { Location: gameEntry });
|
|
349
710
|
return res.end();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@looop-games/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
}
|