@joystack/cli 0.0.0-stage → 0.1.43

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -0,0 +1,719 @@
1
+ // The dev recording store — where a session you just played is kept.
2
+ //
3
+ // In dev the room server, the game folder and the person who played are all on
4
+ // one machine, so a recording has no reason to make a round trip: the room
5
+ // posts each closed segment to the dev server (see sessionIngestUrlFor in
6
+ // shared/ui/room/session-ingest.js) and it lands here, under the game's
7
+ // `.joystack/replays/<stream>/`. Watching a session back is then just reading
8
+ // files that are already on disk — which is what makes `?replay=<id>` a URL
9
+ // you paste rather than a fetch with credentials behind it.
10
+ //
11
+ // A PUBLISHED game is the other case entirely: its room is a Worker, the only
12
+ // durable place is the platform, and segments go to /sessions/ingest (R2 +
13
+ // D1). Nothing here is involved in that lane.
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { BUILD_ID_RE } from './build-id.mjs';
18
+ import { canonicalFormat, stateDir } from './legacy-names.mjs';
19
+
20
+ // The stream id is minted room-side from a timestamp and a counter, and it is
21
+ // interpolated into a filesystem path — so its charset is pinned here rather
22
+ // than trusted. Anything with a separator, a dot segment, or nothing in it is
23
+ // refused outright instead of being sanitized into something plausible.
24
+ const STREAM_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
+
26
+ // Where a dev room posts its closed segments. The engine derives the same path
27
+ // in shared/ui/room/session-ingest.js (DEV_SEGMENT_PATH there) — the two ends
28
+ // of one wire, kept spelled out in both so neither can drift silently.
29
+ export const DEV_SEGMENT_PATH = '/__joystack/segments';
30
+
31
+ // Where the dev page asks what recordings exist, deletes one, or pins one
32
+ // against the prune — the read half of the same lane. Spelled out in the
33
+ // engine's Sessions tool too (shared/ui/sessions/sessions.js), for the same
34
+ // reason as above: two ends of one wire, neither able to drift silently.
35
+ export const DEV_REPLAYS_PATH = '/__joystack/replays';
36
+
37
+ export function replaysRoot(projectDir) {
38
+ return join(stateDir(projectDir), 'replays');
39
+ }
40
+
41
+ export function streamDir(projectDir, stream) {
42
+ if (!STREAM_RE.test(stream ?? '')) throw new Error(`invalid stream id: ${JSON.stringify(stream)}`);
43
+ return join(replaysRoot(projectDir), stream);
44
+ }
45
+
46
+ /**
47
+ * Persist one closed segment. The address is (stream, seq), the same pair the
48
+ * production index uses, so a room's bounded retry rewrites the same file
49
+ * rather than appending a duplicate.
50
+ * @param {string} projectDir - the game folder
51
+ * @param {{ slug?: string, room?: string, segment: object }} body - the room's POST body
52
+ * @param {{ maxTrackBytes?: number, stamp?: () => object }} [opts] - the per-session
53
+ * track limit (tests), and the build stamp (see `stampBuild`)
54
+ * @returns {{ stored: true, stream: string, seq: number }}
55
+ */
56
+ export function writeSegment(projectDir, body, opts = {}) {
57
+ const segment = body?.segment;
58
+ const header = segment?.header;
59
+ if (!header || typeof header !== 'object') throw new Error('not a segment: no header');
60
+ const format = canonicalFormat(header.format);
61
+ if (format === TRACK_SEGMENT_FORMAT) return writeTrackSegment(projectDir, segment, opts);
62
+ if (format !== 'joystack-replay-seg/1') {
63
+ throw new Error(`not a segment: format ${JSON.stringify(header.format)}`);
64
+ }
65
+ // Bounded above as well as below: `seq` names the file, and while no number's
66
+ // string form can contain a separator, an unbounded one lets a sender pick
67
+ // arbitrary names in the stream directory (`1e21`, `9007199254740992`) that
68
+ // the reader's lexical sort then misorders. A stream that reaches a million
69
+ // segments is a bug, not a session.
70
+ //
71
+ // The bound is EXCLUSIVE because the filename is six digits: an accepted
72
+ // 1000000 would write `1000000.json`, which SEGMENT_FILE_RE never matches, so
73
+ // the store would answer `stored: true` (stopping the room's retries) to a
74
+ // segment no reader can see and no retention cap can ever sweep.
75
+ if (!Number.isInteger(header.seq) || header.seq < 0 || header.seq >= 1e6) {
76
+ throw new Error('not a segment: bad seq');
77
+ }
78
+ if (!Array.isArray(segment.events)) throw new Error('not a segment: no events');
79
+ const dir = streamDir(projectDir, header.stream);
80
+ mkdirSync(dir, { recursive: true });
81
+ writeFileSync(join(dir, segmentFileName(header.seq)), JSON.stringify(segment));
82
+ updateIndex(dir, segment);
83
+ if (opts.stamp) stampBuild(dir, opts.stamp);
84
+ return { stored: true, stream: header.stream, seq: header.seq };
85
+ }
86
+
87
+ // ── the per-stream index ────────────────────────────────────────────────────
88
+ //
89
+ // The Sessions panel asks each recording when it was played, how long it ran
90
+ // and how many inputs it carries. Every one of those lives in the segments, so
91
+ // answering from the segments costs the whole recording — a five-minute session
92
+ // is megabytes, and the panel asks about twenty of them at once, on a phone.
93
+ // So each segment write also folds its own contribution into a small file
94
+ // beside the segments, and reading a stream's headline facts is then one small
95
+ // read instead of a full parse.
96
+ //
97
+ // The counts are held PER SEQ rather than as a running total because a segment
98
+ // can legitimately arrive twice: a room's flush retries are idempotent by
99
+ // (stream, seq) and rewrite the same file, so a running total would count that
100
+ // segment's inputs again every time the network hiccuped.
101
+ //
102
+ // Keyed as an OBJECT rather than indexed into an array, because `seq` comes off
103
+ // the wire from an endpoint nothing authenticates. An array indexed at seq
104
+ // 999999 serialises 999999 holes as `null` — so a 160-byte POST would write
105
+ // megabytes here, none of it visible to the retention caps, and every later
106
+ // listing would reduce over a million elements inside the dev server's one
107
+ // thread. An object costs one entry per segment that actually exists.
108
+ //
109
+ // The index is derived, never authoritative: it is rebuilt from the segments
110
+ // whenever it is missing or was written by an older shape, so deleting it is a
111
+ // safe thing for anyone to do and an older recording gains one on first read.
112
+ const INDEX_FILE = 'index.json';
113
+ const INDEX_VERSION = 2;
114
+
115
+ // Segments are the six-digit files; everything else in the directory (this
116
+ // index, the keep marker) is bookkeeping and is not a segment.
117
+ const SEGMENT_FILE_RE = /^\d{6}\.json$/;
118
+
119
+ // A TRACK segment (project note ofta45) is the cost half of a session: the
120
+ // same stream, written by a different producer — in multiplayer, by each
121
+ // player's browser rather than by the room. It lands FLAT in the stream's
122
+ // directory rather than in a subfolder, so every byte of it is visible to the
123
+ // retention caps and one `rm -rf` of the directory still takes the whole
124
+ // session with it.
125
+ //
126
+ // `<kind>.<producer>.<seq>.json`, with `.` as the separator precisely because
127
+ // neither field may contain one: the name parses back into its three parts
128
+ // with no ambiguity, and no value either field can hold reaches another
129
+ // directory.
130
+ const TRACK_SEGMENT_FORMAT = 'joystack-track-seg/1';
131
+ const TRACK_KIND_RE = /^[a-z0-9][a-z0-9-]{0,15}$/;
132
+ // Long enough for a whole connection id (a UUID is 36 characters): the
133
+ // producer is what joins a browser's frames to the recording's own record of
134
+ // that player, so a name cut short of the id joins nothing.
135
+ const TRACK_PRODUCER_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
136
+ const TRACK_FILE_RE = /^([a-z0-9][a-z0-9-]{0,15})\.([a-z0-9][a-z0-9-]{0,63})\.(\d{6})\.json$/;
137
+
138
+ function segmentFileName(seq) {
139
+ // Zero-padded so a directory listing is already in stream order — the same
140
+ // reason the R2 key is padded.
141
+ return `${String(seq).padStart(6, '0')}.json`;
142
+ }
143
+
144
+ /**
145
+ * Persist one closed TRACK segment — the cost half of a session, written by
146
+ * the browser that measured it. The address is (stream, kind, producer, seq),
147
+ * so a browser's bounded retry rewrites the same file, and two players in one
148
+ * room never collide.
149
+ *
150
+ * A track is additive: the recording is complete without it, and a reader that
151
+ * finds none says so rather than failing. So nothing here touches the
152
+ * recording's index — a track segment is not a recording segment, and counting
153
+ * it as one would misreport how much of the session is on disk.
154
+ */
155
+ // What one session's cost track may take up, and what one segment of it may
156
+ // be. A real track runs about 1 MB a minute, so the per-session ceiling is
157
+ // several hours of play: long enough that no session a person plays reaches
158
+ // it. It exists because the endpoint that accepts a track segment is the dev
159
+ // server's, open to anything that can reach it (a second player's browser is
160
+ // the intended case), and the bytes land in a folder the retention sweep
161
+ // measures: without a ceiling, one sender could push a session past the
162
+ // folder's byte cap (MAX_RECORDING_BYTES, several times this) and make the
163
+ // sweep delete OTHER sessions to get back under it.
164
+ export const MAX_TRACK_BYTES_PER_STREAM = 512 * 1024 * 1024;
165
+ export const MAX_TRACK_SEGMENT_BYTES = 2 * 1024 * 1024;
166
+
167
+ // What the track already occupies in this stream's folder.
168
+ function trackBytes(dir) {
169
+ let bytes = 0;
170
+ let names;
171
+ try { names = readdirSync(dir); } catch { return 0; }
172
+ for (const name of names) {
173
+ if (!TRACK_FILE_RE.test(name)) continue;
174
+ try { bytes += statSync(join(dir, name)).size; } catch { /* vanished mid-scan */ }
175
+ }
176
+ return bytes;
177
+ }
178
+
179
+ function writeTrackSegment(projectDir, segment, { maxTrackBytes = MAX_TRACK_BYTES_PER_STREAM } = {}) {
180
+ const h = segment.header;
181
+ if (!TRACK_KIND_RE.test(h.kind ?? '')) throw new Error(`not a track segment: bad kind ${JSON.stringify(h.kind)}`);
182
+ if (!TRACK_PRODUCER_RE.test(h.producer ?? '')) {
183
+ throw new Error(`not a track segment: bad producer ${JSON.stringify(h.producer)}`);
184
+ }
185
+ // Same bound and same reason as a recording segment's seq: the number names
186
+ // the file, and an unbounded one lets a sender pick names no reader matches.
187
+ if (!Number.isInteger(h.seq) || h.seq < 0 || h.seq >= 1e6) throw new Error('not a track segment: bad seq');
188
+ const dir = streamDir(projectDir, h.stream);
189
+ const body = JSON.stringify(segment);
190
+ const name = trackFileName(h.kind, h.producer, h.seq);
191
+ // Refused rather than thrown: a track is additive, so a browser that has
192
+ // written more than its share is told no and the session carries on. The
193
+ // ceilings are decided above; what matters here is that neither one is a
194
+ // reason to fail a POST that also carried a recording.
195
+ if (body.length > MAX_TRACK_SEGMENT_BYTES) {
196
+ return { stored: false, reason: 'too-large', stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
197
+ }
198
+ mkdirSync(dir, { recursive: true });
199
+ // The file being rewritten is a retry of a segment already counted, so its
200
+ // current size comes off the total rather than counting twice.
201
+ let already = trackBytes(dir);
202
+ try { already -= statSync(join(dir, name)).size; } catch { /* first write of this seq */ }
203
+ if (already + body.length > maxTrackBytes) {
204
+ noteTrackCut(dir, h, maxTrackBytes);
205
+ return { stored: false, reason: 'budget', stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
206
+ }
207
+ writeFileSync(join(dir, name), body);
208
+ return { stored: true, stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
209
+ }
210
+
211
+ // Where the per-session limit cut each browser's track, kept beside the
212
+ // segments. A refused stretch is one nobody will ever read, and without this a
213
+ // reader reports it exactly as it reports measuring switched off — a false
214
+ // reason. One entry per (kind, producer), holding the earliest tick refused:
215
+ // segments can arrive out of order (a beacon, a retried post), and the cut is
216
+ // where refusing began. At most MAX_TRACK_CUTS entries, because the producer
217
+ // name is the sender's to choose and a stream already over its limit must not
218
+ // grow a second file without bound.
219
+ const TRACK_LIMIT_FILE = 'track-limit.json';
220
+ const MAX_TRACK_CUTS = 16;
221
+
222
+ function noteTrackCut(dir, header, limitBytes) {
223
+ const fromTick = Number.isFinite(header.startTick) ? header.startTick : null;
224
+ const file = join(dir, TRACK_LIMIT_FILE);
225
+ let record = null;
226
+ try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { /* the first cut */ }
227
+ const cuts = Array.isArray(record?.cuts) ? record.cuts : [];
228
+ const at = cuts.find((c) => c?.kind === header.kind && c?.producer === header.producer);
229
+ if (at) {
230
+ if (fromTick === null || (at.fromTick !== null && at.fromTick <= fromTick)) return;
231
+ at.fromTick = fromTick;
232
+ } else {
233
+ if (cuts.length >= MAX_TRACK_CUTS) return;
234
+ cuts.push({ kind: header.kind, producer: header.producer, fromTick });
235
+ }
236
+ try {
237
+ writeFileSync(file, JSON.stringify({ limitBytes, cuts }));
238
+ } catch {
239
+ // The note is for the reader; failing to write it costs the explanation,
240
+ // never the refusal it explains.
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Where the per-session limit cut this stream's track, or null when it never
246
+ * did.
247
+ * @returns {{ limitBytes: number, cuts: { kind: string, producer: string, fromTick: number|null }[] }|null}
248
+ */
249
+ export function readTrackLimits(projectDir, stream) {
250
+ let record;
251
+ try {
252
+ record = JSON.parse(readFileSync(join(streamDir(projectDir, stream), TRACK_LIMIT_FILE), 'utf8'));
253
+ } catch {
254
+ return null;
255
+ }
256
+ const cuts = (Array.isArray(record?.cuts) ? record.cuts : [])
257
+ .filter((c) => c && typeof c === 'object' && typeof c.producer === 'string' && typeof c.kind === 'string')
258
+ .map((c) => ({ kind: c.kind, producer: c.producer, fromTick: Number.isFinite(c.fromTick) ? c.fromTick : null }));
259
+ if (!cuts.length) return null;
260
+ return { limitBytes: Number.isFinite(record.limitBytes) ? record.limitBytes : null, cuts };
261
+ }
262
+
263
+ function trackFileName(kind, producer, seq) {
264
+ return `${kind}.${producer}.${String(seq).padStart(6, '0')}.json`;
265
+ }
266
+
267
+ /**
268
+ * Every track segment of one kind in a stream, in producer then seq order.
269
+ *
270
+ * Empty when the session carries no track: a recording made with measuring
271
+ * switched off, or by an engine from before tracks existed. That is a
272
+ * different answer from "no such recording", and the caller reports it as a
273
+ * session whose cost was not recorded.
274
+ *
275
+ * @param {string} projectDir - the game folder
276
+ * @param {string} stream - the recording's stream id
277
+ * @param {string} [kind] - which track, 'perf' by default
278
+ */
279
+ export function readTrackSegments(projectDir, stream, kind = 'perf') {
280
+ const dir = streamDir(projectDir, stream);
281
+ let names;
282
+ try { names = readdirSync(dir); } catch { return []; }
283
+ const out = [];
284
+ for (const name of names.sort()) {
285
+ const m = TRACK_FILE_RE.exec(name);
286
+ if (!m || m[1] !== kind) continue;
287
+ try {
288
+ out.push(JSON.parse(readFileSync(join(dir, name), 'utf8')));
289
+ } catch {
290
+ // A half-written file — the dev server was killed mid-POST. The same
291
+ // tolerance the recording's own rebuild has: one lost segment, not a
292
+ // lost session.
293
+ }
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /** Which kinds of track a stream carries, sorted. */
299
+ function trackKinds(names) {
300
+ const kinds = new Set();
301
+ for (const name of names) {
302
+ const m = TRACK_FILE_RE.exec(name);
303
+ if (m) kinds.add(m[1]);
304
+ }
305
+ return [...kinds].sort();
306
+ }
307
+
308
+ function countInputs(segment) {
309
+ // A recorded INPUT is a `w` — an accepted wire send, the player doing
310
+ // something. Spawns, roster changes and checksums are the world's own
311
+ // bookkeeping and are not things anybody did.
312
+ return (segment?.events ?? []).reduce((n, e) => n + (e?.k === 'w' ? 1 : 0), 0);
313
+ }
314
+
315
+ function foldSegment(index, segment) {
316
+ const h = segment.header;
317
+ const seq = h.seq;
318
+ // Anchored on the SEQ alone. `startedAt` is null in a runtime with no Date
319
+ // (recorder.js writes it conditionally), and keying on "we have no stamp yet"
320
+ // would make every later segment re-anchor — so `startTick` would track the
321
+ // last segment written and the duration would collapse to one segment's span,
322
+ // or go negative.
323
+ if (seq < (index.firstSeq ?? Infinity)) {
324
+ index.firstSeq = seq;
325
+ index.startedAt = h.startedAt ?? null;
326
+ index.startTick = h.startTick ?? 0;
327
+ }
328
+ if (h.tickRate) index.tickRate = h.tickRate;
329
+ // Whether this stream can be ENTERED. A replay starts at a keyframe, so a
330
+ // stream that has not cut one yet is on disk and not yet watchable — a
331
+ // not-yet, not a corruption, and a different answer from "no such recording".
332
+ if (h.keyframe) index.hasKeyframe = true;
333
+ const end = segment.endTick ?? h.startTick ?? 0;
334
+ index.endTick = Math.max(index.endTick ?? -Infinity, end);
335
+ index.inputsBySeq[String(seq)] = countInputs(segment);
336
+ return index;
337
+ }
338
+
339
+ function emptyIndex(stream) {
340
+ return { v: INDEX_VERSION, stream, startedAt: null, firstSeq: null, startTick: 0, endTick: null,
341
+ tickRate: null, hasKeyframe: false, inputsBySeq: {} };
342
+ }
343
+
344
+ // Read every segment in a stream and fold them into a fresh index. The slow
345
+ // path, taken once per stream: on the first write, and on the first read of a
346
+ // recording made before this file existed.
347
+ function rebuildIndex(dir, stream) {
348
+ const index = emptyIndex(stream);
349
+ for (const name of readdirSync(dir).sort()) {
350
+ if (!SEGMENT_FILE_RE.test(name)) continue;
351
+ try {
352
+ const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
353
+ if (canonicalFormat(seg?.header?.format) === 'joystack-replay-seg/1') foldSegment(index, seg);
354
+ } catch {
355
+ // A half-written file (the dev server was killed mid-POST) must not cost
356
+ // the rest of the session — the same tolerance stitching has.
357
+ }
358
+ }
359
+ return index;
360
+ }
361
+
362
+ function readIndex(dir) {
363
+ try {
364
+ const index = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'));
365
+ if (index?.v === INDEX_VERSION && index.inputsBySeq && typeof index.inputsBySeq === 'object'
366
+ && !Array.isArray(index.inputsBySeq)) return index;
367
+ } catch {
368
+ // Missing, half-written, or an older shape — all one case: rebuild.
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function writeIndex(dir, index) {
374
+ try {
375
+ writeFileSync(join(dir, INDEX_FILE), JSON.stringify(index));
376
+ } catch {
377
+ // The index is a cache. A game folder that has gone read-only must not
378
+ // turn a recorded session into a failed POST.
379
+ }
380
+ }
381
+
382
+ function updateIndex(dir, segment) {
383
+ // Absent means rebuild rather than start over: an index deleted mid-stream
384
+ // would otherwise silently lose every segment written before it went.
385
+ const existing = readIndex(dir);
386
+ writeIndex(dir, existing ? foldSegment(existing, segment) : rebuildIndex(dir, segment.header.stream));
387
+ }
388
+
389
+ // ── the read side ───────────────────────────────────────────────────────────
390
+
391
+ // Presence is the whole marker: a recording under triage must survive the
392
+ // prune, and the flag lives in its own FILE rather than in the index because
393
+ // the index is rewritten on every segment write — a keep toggled while that
394
+ // stream is still recording would lose the race. Deleting the directory still
395
+ // takes the marker with it.
396
+ const KEEP_FILE = 'keep';
397
+
398
+ // ── the build stamp ─────────────────────────────────────────────────────────
399
+ //
400
+ // Which code recorded the session: the game's build id (build-id.mjs), the
401
+ // commit its files came from and whether they differed from it, and the engine
402
+ // version the dev server was serving. A recording's own header cannot say
403
+ // this. The page and the room know neither the game's files nor its git, and
404
+ // a room served straight off `joystack dev` carries a placeholder where a
405
+ // published build carries its engine version.
406
+ //
407
+ // Taken once per session, when its first recording segment lands — which is
408
+ // within seconds of the page loading the code it records. Never taken for a
409
+ // track segment: a browser's cost track is not the recording, and a stream
410
+ // holding only tracks has no code to vouch for. A stamp that fails (no git, a
411
+ // file vanished mid-walk) is simply absent: the recording it describes is
412
+ // worth more than the description.
413
+ const BUILD_FILE = 'build.json';
414
+
415
+ // Sessions this process has already tried to stamp. A stamp that failed is
416
+ // not tried again on every later segment of the same session.
417
+ const stampTried = new Set();
418
+
419
+ function stampBuild(dir, take) {
420
+ const file = join(dir, BUILD_FILE);
421
+ if (stampTried.has(file) || existsSync(file)) return;
422
+ stampTried.add(file);
423
+ try {
424
+ writeFileSync(file, JSON.stringify({ ...take(), stampedAt: Date.now() }));
425
+ } catch {
426
+ // See above: the recording is stored either way.
427
+ }
428
+ }
429
+
430
+ const COMMIT_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
431
+ const ENGINE_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
432
+
433
+ /**
434
+ * A session's build stamp, or null when it has none. The file sits in a folder
435
+ * anyone can copy a recording into, so each field is kept only when it is the
436
+ * shape it claims and is otherwise null — never sanitized into something that
437
+ * looks right.
438
+ *
439
+ * @returns {{ id: string|null, files: number|null, git: { commit: string, dirty: boolean|null }|null,
440
+ * engine: string|null, stampedAt: number|null } | null}
441
+ */
442
+ export function readBuildStamp(projectDir, stream) {
443
+ let raw;
444
+ try { raw = JSON.parse(readFileSync(join(streamDir(projectDir, stream), BUILD_FILE), 'utf8')); } catch { return null; }
445
+ if (!raw || typeof raw !== 'object') return null;
446
+ const is = (v, re) => typeof v === 'string' && re.test(v);
447
+ const git = raw.git && typeof raw.git === 'object' && is(raw.git.commit, COMMIT_RE)
448
+ ? { commit: raw.git.commit, dirty: typeof raw.git.dirty === 'boolean' ? raw.git.dirty : null }
449
+ : null;
450
+ return {
451
+ id: is(raw.id, BUILD_ID_RE) ? raw.id : null,
452
+ files: Number.isSafeInteger(raw.files) && raw.files >= 0 ? raw.files : null,
453
+ git,
454
+ engine: typeof raw.engine === 'string' && ENGINE_RE.test(raw.engine) ? raw.engine : null,
455
+ stampedAt: Number.isFinite(raw.stampedAt) ? raw.stampedAt : null,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Every recording this game has on disk, newest first.
461
+ *
462
+ * Each entry carries what the Sessions panel shows: when it was played, how
463
+ * long it ran, how many inputs it holds, what it weighs, and whether it is
464
+ * pinned against the prune. Cost is one small read and a stat per file — never
465
+ * a parse of the recordings themselves (see the index above).
466
+ *
467
+ * @param {string} projectDir - the game folder
468
+ * @returns {Array<{ stream: string, startedAt: number|null, durationMs: number|null,
469
+ * inputs: number, segments: number, bytes: number, kept: boolean }>}
470
+ */
471
+ export function listRecordings(projectDir) {
472
+ const root = replaysRoot(projectDir);
473
+ if (!existsSync(root)) return [];
474
+ const out = [];
475
+ for (const stream of readdirSync(root)) {
476
+ // Only names this store could have minted. A creator copying a teammate's
477
+ // recording in calls the directory whatever they like, and listing one made
478
+ // `deleteRecording` throw on a name `streamDir` refuses — inside a prune
479
+ // that runs before `joystack dev` binds a port, so the whole stack failed to
480
+ // start over a directory name. The two functions agree on the charset here.
481
+ if (!STREAM_RE.test(stream)) continue;
482
+ const dir = join(root, stream);
483
+ let names;
484
+ try {
485
+ if (!statSync(dir).isDirectory()) continue;
486
+ names = readdirSync(dir);
487
+ } catch {
488
+ continue;
489
+ }
490
+ const segments = names.filter((n) => SEGMENT_FILE_RE.test(n));
491
+ const tracks = trackKinds(names);
492
+ // A folder with neither is not this store's.
493
+ if (!segments.length && !tracks.length) continue;
494
+ // A folder holding ONLY tracks is listed too, with nothing to watch. It is
495
+ // either a session whose recording never landed or bytes somebody posted
496
+ // at a stream id no recording ever used — and both are the store's to
497
+ // account for: the retention caps bound a folder, and anything left out of
498
+ // the inventory is something the prune can never reclaim however much of
499
+ // it there is. A track is written by a browser over the same open dev
500
+ // endpoint the recording uses, so "however much" is not hypothetical.
501
+ let index = segments.length ? readIndex(dir) : (readIndex(dir) ?? emptyIndex(stream));
502
+ if (!index) {
503
+ index = rebuildIndex(dir, stream);
504
+ writeIndex(dir, index);
505
+ }
506
+ // Every file, not only the segments: the caps exist to bound a folder, and
507
+ // anything left out of the measurement is something the prune can never
508
+ // reclaim however much of it there is.
509
+ let bytes = 0;
510
+ for (const name of names) {
511
+ try { bytes += statSync(join(dir, name)).size; } catch { /* the file vanished mid-scan */ }
512
+ }
513
+ const ticks = index.endTick == null ? null : index.endTick - (index.startTick ?? 0);
514
+ out.push({
515
+ stream,
516
+ startedAt: index.startedAt ?? null,
517
+ // The tick span, which is the axis a performance track joins on.
518
+ startTick: index.startTick ?? 0,
519
+ endTick: index.endTick ?? null,
520
+ durationMs: ticks != null && index.tickRate ? (ticks / index.tickRate) * 1000 : null,
521
+ tickRate: index.tickRate ?? null,
522
+ inputs: Object.values(index.inputsBySeq).reduce((n, v) => n + (v ?? 0), 0),
523
+ segments: segments.length,
524
+ bytes,
525
+ // A replay enters at a keyframe. A stream a few seconds old has not cut
526
+ // one yet, so it is on disk and not yet watchable.
527
+ watchable: index.hasKeyframe === true,
528
+ // Which cost tracks were recorded alongside it, if any (project note
529
+ // ofta45). Empty is the ordinary answer for a session played with
530
+ // measuring switched off.
531
+ tracks,
532
+ // Which code recorded it, when the dev server that stored it said so.
533
+ build: readBuildStamp(projectDir, stream),
534
+ kept: existsSync(join(dir, KEEP_FILE)),
535
+ });
536
+ }
537
+ // Newest first, by the wall clock the room stamped rather than by mtime: a
538
+ // copied folder keeps its play order, and the list reads the way the person
539
+ // remembers playing. Streams with no stamp sort last rather than first.
540
+ return out.sort((a, b) => (b.startedAt ?? -Infinity) - (a.startedAt ?? -Infinity));
541
+ }
542
+
543
+ /**
544
+ * The most recent recording that can actually be WATCHED, or null.
545
+ *
546
+ * Newest-that-is-watchable rather than newest: a stream cuts its first keyframe
547
+ * a few seconds in, so the very newest recording is routinely not enterable
548
+ * yet — and `?replay=last` resolving to it would answer "no recording ... it is
549
+ * recorded on the machine that played it" while a perfectly watchable session
550
+ * sits right behind it, on the machine asking.
551
+ */
552
+ export function latestRecording(projectDir) {
553
+ return listRecordings(projectDir).find((r) => r.watchable)?.stream ?? null;
554
+ }
555
+
556
+ /**
557
+ * Delete one recording, whole. Returns false when there was nothing there —
558
+ * deleting a session twice is not an error, it is the second tap.
559
+ */
560
+ export function deleteRecording(projectDir, stream) {
561
+ const dir = streamDir(projectDir, stream);
562
+ if (!existsSync(dir)) return false;
563
+ rmSync(dir, { recursive: true, force: true });
564
+ return true;
565
+ }
566
+
567
+ /**
568
+ * Pin a recording against the prune, or unpin it. Returns false when there is
569
+ * no such recording.
570
+ */
571
+ export function setKept(projectDir, stream, kept) {
572
+ const dir = streamDir(projectDir, stream);
573
+ if (!existsSync(dir)) return false;
574
+ const marker = join(dir, KEEP_FILE);
575
+ if (kept) writeFileSync(marker, '');
576
+ else rmSync(marker, { force: true });
577
+ return true;
578
+ }
579
+
580
+ // What a game's recordings may take up before the oldest start going. Two caps
581
+ // because either one alone has a case it cannot see: a game with big worlds
582
+ // hits the byte cap after a handful of sessions, and a small game would sit at
583
+ // a few megabytes while accumulating hundreds of directories.
584
+ export const MAX_RECORDINGS = 20;
585
+ // A real session writes about 2.7 MB a minute of recording and 1 MB of track,
586
+ // so the byte cap holds well over ten hours of play; for most games the count
587
+ // is the cap that bites.
588
+ export const MAX_RECORDING_BYTES = 3 * 1024 * 1024 * 1024;
589
+
590
+ /**
591
+ * Bring a game's recordings back under the caps, oldest first.
592
+ *
593
+ * Pinned recordings are never swept, and they still COUNT against the caps —
594
+ * pinning is "do not delete this", not "do not charge me for it". Pin
595
+ * everything and the caps simply stop having anything to take, which is the
596
+ * honest outcome: the alternative silently deletes an unpinned session the
597
+ * creator only just made.
598
+ *
599
+ * @param {string} projectDir - the game folder
600
+ * @param {{ maxCount?: number, maxBytes?: number }} [caps]
601
+ * @returns {{ removed: string[], bytes: number, count: number, caps: { maxCount: number, maxBytes: number } }}
602
+ * what went, what is left, and the caps that decided it
603
+ */
604
+ export function pruneRecordings(projectDir, { maxCount = MAX_RECORDINGS, maxBytes = MAX_RECORDING_BYTES } = {}) {
605
+ // Oldest first: the sweep order is the reverse of the reading order.
606
+ const all = listRecordings(projectDir).reverse();
607
+ let count = all.length;
608
+ let bytes = all.reduce((n, r) => n + r.bytes, 0);
609
+ const removed = [];
610
+ for (const rec of all) {
611
+ if (count <= maxCount && bytes <= maxBytes) break;
612
+ if (rec.kept) continue;
613
+ // A delete that fails for its own reason (a permission, a file held open)
614
+ // costs that one recording, never the startup this runs inside.
615
+ try {
616
+ if (!deleteRecording(projectDir, rec.stream)) continue;
617
+ } catch {
618
+ continue;
619
+ }
620
+ removed.push(rec.stream);
621
+ count -= 1;
622
+ bytes -= rec.bytes;
623
+ }
624
+ return { removed, bytes, count, caps: { maxCount, maxBytes } };
625
+ }
626
+
627
+ /**
628
+ * Read a whole stream back as the single playable recording the replay
629
+ * transport consumes, or null when there is nothing enterable there.
630
+ *
631
+ * Stitching goes through the ENGINE's `stitchSegments` rather than a copy: it
632
+ * is the same reassembler the recorder's own export() uses, which is what
633
+ * keeps "segments are just a slicing of the recording" true by construction.
634
+ * It is imported from the resolved engine bundle, so a recording is always
635
+ * read back by the engine that wrote it — from `entity/stitch.js`, which is
636
+ * the reassembler alone, rather than through the recorder and its runtime
637
+ * dependencies.
638
+ *
639
+ * @param {string} projectDir - the game folder
640
+ * @param {string} stream
641
+ * @param {string} sharedDir - the engine bundle's shared/ tree
642
+ * @returns {Promise<string|null>} the recording as JSON text
643
+ */
644
+ export async function stitchStream(projectDir, stream, sharedDir) {
645
+ const dir = streamDir(projectDir, stream);
646
+ if (!existsSync(dir)) return null;
647
+ const segments = [];
648
+ for (const name of readdirSync(dir).sort()) {
649
+ if (!SEGMENT_FILE_RE.test(name)) continue;
650
+ try {
651
+ const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
652
+ if (canonicalFormat(seg?.header?.format) === 'joystack-replay-seg/1') segments.push(seg);
653
+ } catch {
654
+ // A half-written file (the dev server was killed mid-POST) must not
655
+ // cost the rest of the session.
656
+ }
657
+ }
658
+ if (!segments.length) return null;
659
+ // The stitcher belongs to the ENGINE, and the engine a game is pinned to may
660
+ // predate recordings entirely — a creator on a new CLI and an old engine can
661
+ // record perfectly well (the room is the new engine's; the segments are on
662
+ // disk) and then find nothing to read them with. Said plainly here, because
663
+ // the alternative is a module-resolution error surfacing to that creator as
664
+ // "no recording", with the files sitting in front of them.
665
+ const stitcher = join(sharedDir, 'ui', 'room', 'entity', 'stitch.js');
666
+ if (!existsSync(stitcher)) {
667
+ throw new Error(
668
+ "this game's pinned engine predates session recordings, so it cannot read one back — "
669
+ + 'run `joystack update` to watch this session',
670
+ );
671
+ }
672
+ const { stitchSegments } = await import(pathToFileURL(stitcher).href);
673
+ try {
674
+ return JSON.stringify(stitchSegments(segments));
675
+ } catch {
676
+ // The only way stitching refuses a contiguous range is "no keyframe
677
+ // segment in it" — a stream whose first cadence keyframe has not been cut
678
+ // yet. That is a not-yet, not a corruption.
679
+ return null;
680
+ }
681
+ }
682
+
683
+ // A byte cap as a person reads it: whole gigabytes when it is one, else MB.
684
+ const GB = 1024 * 1024 * 1024;
685
+ const sizeLabel = (bytes) => (bytes >= GB && bytes % GB === 0
686
+ ? `${bytes / GB} GB`
687
+ : `${Math.round(bytes / 1024 / 1024)} MB`);
688
+
689
+ /**
690
+ * The prune `joystack dev` runs on the way up, and the line it prints.
691
+ *
692
+ * On start rather than on write: a sweep mid-session could delete the
693
+ * recording of the session the creator is in the middle of watching back, and
694
+ * the moment a stack starts is the one moment nobody is reading anything.
695
+ * Silent deletion is not an option either — a creator who goes looking for
696
+ * Tuesday's session deserves to have been told it went, so the line names the
697
+ * count and the caps that took it.
698
+ */
699
+ export function pruneOnStart({ projectDir, log = () => {}, caps } = {}) {
700
+ // This runs before `joystack dev` has bound a port. Housekeeping over old
701
+ // recordings is never a reason for the stack not to start, so whatever the
702
+ // disk does here costs the sweep and nothing else.
703
+ let result;
704
+ try {
705
+ result = pruneRecordings(projectDir, caps);
706
+ } catch (e) {
707
+ log(`→ replays could not sweep old recordings: ${e?.message ?? e}`);
708
+ return { removed: [], bytes: 0, count: 0, caps: { maxCount: MAX_RECORDINGS, maxBytes: MAX_RECORDING_BYTES } };
709
+ }
710
+ if (result.removed.length) {
711
+ // The caps the sweep ACTUALLY applied, not the module defaults: a line that
712
+ // names limits a caller overrode would be telling the creator their session
713
+ // went for a reason that was not the reason.
714
+ log(`→ replays swept ${result.removed.length} old recording(s) — keeping the newest `
715
+ + `${result.caps.maxCount} and up to ${sizeLabel(result.caps.maxBytes)} `
716
+ + "(pin one in the toolbox's Sessions tool to keep it)");
717
+ }
718
+ return result;
719
+ }