@homie-rocks/studio 0.30.1 → 0.31.0

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 (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
package/lib/perf.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
  * load ms from opening the page to the first meaningful frame (`look`: the play page's arrival card on the
19
19
  * screen, the game's title and art; a page without one, the game's first animation frame), to the game's
20
20
  * first animation frame, to a seat, and to playable (seated with a body that the game's port probe reports,
21
- * or seated and drawing when it has none, and the arrival card lifted); what the game's own files weighed on
22
- * the wire, the biggest first
21
+ * or seated and drawing when it has none, and the arrival card lifted: control-ready), and beside it
22
+ * `ready`: when the game itself said it was ready (net.playable()), with the arrival mode; what the
23
+ * game's own files weighed on the wire, the biggest first
23
24
  * frames the time between animation frames: median, 95th and 99th percentile, the worst, and the share of frames
24
25
  * over 33 ms (a dropped frame at 60 Hz, twice) and over 50 ms (a hitch anyone sees)
25
26
  * work the game's JavaScript inside each animation frame (every requestAnimationFrame callback, timed)
@@ -44,10 +45,12 @@
44
45
  import { createHash } from 'node:crypto';
45
46
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
46
47
  import { cpus, loadavg, tmpdir, totalmem } from 'node:os';
47
- import { join, relative, resolve } from 'node:path';
48
+ import { basename, join, relative, resolve } from 'node:path';
48
49
  import { gzipSync } from 'node:zlib';
50
+ import { bundleOf, gameDigest } from './build.mjs';
49
51
  import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
50
- import { LAUNCH_TIMEOUT_MS } from './check.mjs';
52
+ import { CUT_OFF, LAUNCH_TIMEOUT_MS, arrivalFacts, linkOf } from './check.mjs';
53
+ import { reachSite, siteRefusal } from './net.mjs';
51
54
  import { judge, judgePaired, round, summarize } from './perf-stats.mjs';
52
55
  import { readCode } from './perf-code.mjs';
53
56
  import { sourceMapLookup, summarizeProfile } from './perf-profile.mjs';
@@ -154,6 +157,42 @@ function busyBetween(a, b) {
154
157
  return total ? round((1 - idle / total) * 100, 1) : null;
155
158
  }
156
159
 
160
+ /**
161
+ * MEASURED PRE-PLAY TRANSFER: what this browser really fetched, on the wire, up to the moment the game was playable.
162
+ * `requests` is the Network domain's map at that moment ({ url, bytes (final, once finished), got (so far), done }).
163
+ * A request still in flight counts the bytes that had arrived. This is traffic, measured in one run on one device;
164
+ * it is not the size of the built game on disk (perf sizes, the asset inventory's shipped payload), which also
165
+ * holds files the game never asks for and music it streams later. null when the game never became playable: then
166
+ * there is no "before play" to measure, and the number is absent rather than a total of something else.
167
+ */
168
+ export function prePlayTransfer(requests, { playable = true } = {}) {
169
+ if (!playable) return null;
170
+ const rows = [...requests];
171
+ const wire = (r) => (r.done ? r.bytes : r.got ?? 0) || 0;
172
+ const game = rows.filter((r) => /\/__game\//.test(r.url));
173
+ return {
174
+ requests: rows.length, finished: rows.filter((r) => r.done).length, inFlight: rows.filter((r) => !r.done).length,
175
+ kb: round(rows.reduce((s, r) => s + wire(r), 0) / 1024, 1),
176
+ gameRequests: game.length, gameKb: round(game.reduce((s, r) => s + wire(r), 0) / 1024, 1),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * MEASURED RUNTIME SCENE COST: the renderer's own counters while the game is played, when the game exposes them on
182
+ * its port probe (`exposePort(net, { extra: { drawCalls: () => renderer.info.render.calls, triangles: () =>
183
+ * renderer.info.render.triangles } })`, as the 3D starters do). `samples`: [{ drawCalls, triangles }]. Returns
184
+ * { samples, drawCalls: { median, max }, triangles: { median, max } }, or null when the game exposes neither: the
185
+ * run then says the cost was not measured, never zero. The asset inventory's draw-call figure is an estimate from
186
+ * the manifest; this one includes procedural geometry, repeated characters, effects and shadow passes.
187
+ */
188
+ export function renderCostOf(samples) {
189
+ const pick = (k) => samples.map((s) => s?.[k]).filter((v) => Number.isFinite(v) && v >= 0);
190
+ const calls = pick('drawCalls'); const tris = pick('triangles');
191
+ if (!calls.length && !tris.length) return null;
192
+ const sum = (xs) => { if (!xs.length) return null; const o = [...xs].sort((a, b) => a - b); return { median: o[Math.floor((o.length - 1) / 2)], max: o[o.length - 1] }; };
193
+ return { samples: Math.max(calls.length, tris.length), drawCalls: sum(calls), triangles: sum(tris) };
194
+ }
195
+
157
196
  /** Open one browser of a run on the play page, instrumented, and wait until it is playable. */
158
197
  async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
159
198
  const dev = PERF_DEVICES[device];
@@ -187,7 +226,8 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
187
226
  h.cpuMeasured = fast && slow ? round(slow / fast, 2) : null;
188
227
  }
189
228
  if (dev.network) await cdp.send('Network.emulateNetworkConditions', dev.network);
190
- cdp.on('Network.requestWillBeSent', (e) => { if (!h.requests.has(e.requestId)) h.requests.set(e.requestId, { url: e.request.url, type: e.type ?? null, bytes: 0, done: false }); });
229
+ cdp.on('Network.requestWillBeSent', (e) => { if (!h.requests.has(e.requestId)) h.requests.set(e.requestId, { url: e.request.url, type: e.type ?? null, bytes: 0, got: 0, done: false }); });
230
+ cdp.on('Network.dataReceived', (e) => { const r = h.requests.get(e.requestId); if (r) r.got += e.encodedDataLength || 0; });
191
231
  cdp.on('Network.loadingFinished', (e) => { const r = h.requests.get(e.requestId); if (r) { r.bytes = e.encodedDataLength; r.done = true; } });
192
232
  cdp.on('Network.webSocketCreated', (e) => h.sockets.set(e.requestId, /\/__net(?:\?|$)/.test(e.url) ? 'net' : 'other'));
193
233
  const wsBytes = (r) => (r.opcode === 2 ? Math.floor((String(r.payloadData).length * 3) / 4) : Buffer.byteLength(String(r.payloadData)));
@@ -196,10 +236,10 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
196
236
  await page.evaluateOnNewDocument(INSTRUMENT);
197
237
  await page.goto(playUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 });
198
238
  const origin = await T(page.evaluate(() => performance.timeOrigin), 8000);
199
- let seatedAt = null; let playableAt = null; let shell = null; let body = null;
239
+ let seatedAt = null; let playableAt = null; let shell = null; let body = null; let prePlay = null;
200
240
  const deadline = Date.now() + 90_000;
201
241
  while (Date.now() < deadline && playableAt === null) {
202
- shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by } : null } : null; }), 5000);
242
+ shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by, mode: a.mode, explicitMs: a.explicitMs, lateMs: a.lateMs } : null } : null; }), 5000);
203
243
  const now = Date.now();
204
244
  if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
205
245
  const g = await inGame(h, () => {
@@ -212,7 +252,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
212
252
  const lifted = !shell?.arrival || shell.arrival.phase === 'done';
213
253
  if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
214
254
  // A body the game reports (its port probe), or no body to wait for (a board, or a game with no probe).
215
- if (!g.port || g.view === 'board' || g.self) { playableAt = now; body = g.port && g.view !== 'board' ? 'reported' : 'none to report'; }
255
+ if (!g.port || g.view === 'board' || g.self) { playableAt = now; prePlay = prePlayTransfer(h.requests.values()); body = g.port && g.view !== 'board' ? 'reported' : 'none to report'; }
216
256
  else if (now - seatedAt > 10_000) { playableAt = null; body = 'never reported by the port probe in 10 s'; break; }
217
257
  }
218
258
  await sleep(50);
@@ -231,10 +271,18 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
231
271
  const look = Number.isFinite(shell?.arrival?.lookMs) ? shell.arrival.lookMs : at(g?.first);
232
272
  h.loadInfo = {
233
273
  lookMs: look,
234
- arrival: shell?.arrival ? { by: shell.arrival.by ?? null, liftedMs: shell.arrival.liftedMs ?? null } : null,
274
+ // Who was to say "playable" and who did (lib/check.mjs arrivalFacts, from the page's __shell.arrival), and
275
+ // `readyMs`: when the GAME ITSELF said it was ready (net.playable(): its character loaded, its world built).
276
+ // `playableMs` below is control-ready as this probe sees it (seated, a body reported, the cover gone); a game
277
+ // under the automatic arrival can be control-ready well before it calls itself ready, and `lateMs` is by how
278
+ // much. Read again after the measured window (readyAfter), since the game's word may come after playable.
279
+ arrival: arrivalFacts(shell?.arrival),
280
+ readyMs: arrivalFacts(shell?.arrival)?.explicitMs ?? null,
235
281
  firstFrameMs: at(g?.first),
236
282
  seatedMs: at(seatedAt),
237
283
  playableMs: at(playableAt),
284
+ // Measured traffic up to playable (prePlayTransfer); null when this browser never got there.
285
+ prePlay,
238
286
  body,
239
287
  gameDomReadyMs: g?.dcl !== null && g?.dcl !== undefined && g?.origin ? at(g.origin + g.dcl) : null,
240
288
  gameFirstPaintMs: g?.fcp !== null && g?.fcp !== undefined && g?.origin ? at(g.origin + g.fcp) : null,
@@ -351,25 +399,28 @@ function mapsFor(root, game) {
351
399
  if (!root) return [];
352
400
  const dir = join(root, '.studio', 'maps', game);
353
401
  const mapFile = join(dir, 'main.js.map');
354
- const built = join(root, 'site', 'dist', 'games', game, 'assets', 'main.js');
402
+ // The bundle the page really loads: named by its content since bundles got hashes (assets/main-<HASH>.js).
403
+ const rel = bundleOf(join(root, 'site', 'dist', 'games', game)) ?? 'assets/main.js';
404
+ const built = join(root, 'site', 'dist', 'games', game, rel);
355
405
  if (!existsSync(mapFile) || !existsSync(built)) return [];
356
406
  try {
357
407
  const want = readFileSync(join(dir, 'main.js.sha256'), 'utf8').trim();
358
408
  if (sha256(readFileSync(built)) !== want) return [];
359
409
  const lookup = sourceMapLookup(JSON.parse(readFileSync(mapFile, 'utf8')));
360
- return [{ match: (url) => new RegExp(`/${game}/__game/assets/main\\.js(?:\\?|$)`).test(url), lookup }];
410
+ const esc = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+ return [{ match: (url) => new RegExp(`/${game}/__game/${esc}(?:\\?|$)`).test(url), lookup }];
361
412
  } catch { return []; }
362
413
  }
363
414
 
364
415
  export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
365
416
 
366
- /** One digest of a built game's files (site/dist/games/<id>), so a run says exactly which build it measured. */
417
+ /**
418
+ * One digest of a built game's files (site/dist/games/<id>), so a run says exactly which build it measured. It is the
419
+ * build's own digest (lib/build.mjs gameDigest): the hash `build` prints, a deploy reports and the live site's
420
+ * manifest answers with, so a perf run, a build line and a deploy line that name the same build say the same hash.
421
+ */
367
422
  export function buildDigest(root, game) {
368
- const dir = root ? join(root, 'site', 'dist', 'games', game) : null;
369
- if (!dir || !existsSync(dir)) return null;
370
- const h = createHash('sha256');
371
- for (const f of listFiles(dir).filter((f) => !f.startsWith('_landing/')).sort()) h.update(`${f}\0${sha256(readFileSync(join(dir, f)))}\n`);
372
- return h.digest('hex').slice(0, 16);
423
+ return root ? gameDigest(join(root, 'site', 'dist', 'games', game)) : null;
373
424
  }
374
425
 
375
426
  function listFiles(dir, rel = '') {
@@ -412,17 +463,34 @@ async function oneRun({ puppeteer, chrome, root, url, game, device, k, seconds,
412
463
  const b = await Promise.all(hs.map((h) => reading(h)));
413
464
  const after = await Promise.all(hs.map((h) => reading(h, { gc: true })));
414
465
  run.load.busyPct = busyBetween(cpu0, cpu1);
466
+ // The renderer's counters, read AFTER the measured window (reading them costs a little, and the window is not
467
+ // to carry it), still playing: three samples a few frames apart.
468
+ const cost = hs.map(() => []);
469
+ for (let n = 0; n < 3; n++) {
470
+ for (const [i, h] of hs.entries()) cost[i].push(await inGame(h, () => { try { const x = window.__homiePort?.info?.().extra; return x ? { drawCalls: Number(x.drawCalls), triangles: Number(x.triangles) } : null; } catch { return null; } }));
471
+ await sleep(120);
472
+ }
415
473
  for (const [i, h] of hs.entries()) {
474
+ // The game's own "ready" may have come after this browser was control-ready: read the arrival once more.
475
+ const late = arrivalFacts(await T(h.page.evaluate(() => { const a = window.__shell?.arrival; return a ? { mode: a.mode, by: a.by, liftedMs: a.liftedMs, explicitMs: a.explicitMs, lateMs: a.lateMs } : null; }), 5000));
476
+ if (late && h.loadInfo) { h.loadInfo.arrival = late; h.loadInfo.readyMs = late.explicitMs; }
416
477
  const win = await windowOf(h, a[i], b[i], seconds);
417
478
  win.heap.afterGcMb = round((after[i].m.JSHeapUsedSize ?? NaN) / 1048576, 2);
418
479
  win.heap.gcGrowthMbPerMin = round((((after[i].m.JSHeapUsedSize ?? NaN) - (a[i].m.JSHeapUsedSize ?? NaN)) / 1048576) / (win.seconds / 60), 2);
419
- const shell = await T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const t = s.stats ?? {}; return { role: t.role ?? null, seat: s.seat ?? null, peers: t.peers ?? null, snapHzOut: t.snapHzOut ?? null, snapHzIn: t.snapHzIn ?? null, inputHzOut: t.inputHzOut ?? null, inputHzIn: t.inputHzIn ?? null, lastSnapBytes: t.lastSnapBytes ?? null, maxSnapBytes: t.maxSnapBytes ?? null, bytesOutPerS: t.bytesOutPerS ?? null, bytesInPerS: t.bytesInPerS ?? null, rtt: t.rtt ?? null, interpDelay: t.interpDelay ?? null, starvedPct: t.starvedPct ?? null, reconnects: t.reconnects ?? null }; }), 5000);
480
+ const shell = await T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const t = s.stats ?? {}; return { role: t.role ?? null, seat: s.seat ?? null, peers: t.peers ?? null, snapHzOut: t.snapHzOut ?? null, snapHzIn: t.snapHzIn ?? null, inputHzOut: t.inputHzOut ?? null, inputHzIn: t.inputHzIn ?? null, lastSnapBytes: t.lastSnapBytes ?? null, maxSnapBytes: t.maxSnapBytes ?? null, bytesOutPerS: t.bytesOutPerS ?? null, bytesInPerS: t.bytesInPerS ?? null, rtt: t.rtt ?? null, interpDelay: t.interpDelay ?? null, starvedPct: t.starvedPct ?? null, reconnects: t.reconnects ?? null, link: s.link && typeof s.link === 'object' ? s.link.state ?? null : null, stale: s.stale ? s.stale.ver ?? true : null, reloaded: s.reloaded ?? 0 }; }), 5000);
420
481
  const role = shell?.role === 'host' ? 'host' : 'replica';
482
+ // A browser that ended the window cut off from its room (the helper's link: reconnecting, alone, offline,
483
+ // closed) was not measured playing with the other one: the run is labelled and left out, never judged.
484
+ if (shell) shell.link = linkOf(shell.link);
485
+ // A newer build of the game went live while this ran (the helper said `stale`, or the page loaded the game
486
+ // again for it): the window is of the older build, or spans a reload. Labelled and left out, never judged.
487
+ if (shell?.stale || shell?.reloaded > 0) run.blocked ??= `a newer build of the game went live during this run (the ${role}'s page ${shell.reloaded > 0 ? 'loaded the game again for it' : `says its build is stale${typeof shell.stale === 'string' ? `: ${shell.stale} is live` : ''}`}): this run did not measure one build and is left out`;
488
+ if (CUT_OFF.includes(shell?.link)) run.blocked ??= `the ${role} was cut off from its room when the measured window ended (link: ${shell.link}, ${shell.reconnects ?? '?'} reconnects): this run is not two browsers playing together and is left out`;
421
489
  const shot = join(out, `${name}-${role}.png`);
422
490
  const vp = PERF_DEVICES[device].viewport;
423
491
  const scale = Math.min(1, 640 / (vp.width * vp.deviceScaleFactor));
424
492
  await T(h.page.screenshot({ path: shot, type: 'png', clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale } }), 15_000);
425
- run.browsers.push({ role, seat: shell?.seat ?? null, load: h.loadInfo, ...win, netplay: shell, errors: h.errors.slice(0, 10), screenshot: existsSync(shot) ? relative(out, shot) : null });
493
+ run.browsers.push({ role, seat: shell?.seat ?? null, load: h.loadInfo, ...win, render: renderCostOf(cost[i]) ?? { samples: 0, drawCalls: null, triangles: null, note: 'not exposed by the game (exposePort extra: drawCalls, triangles): runtime scene cost was not measured' }, netplay: shell, errors: h.errors.slice(0, 10), screenshot: existsSync(shot) ? relative(out, shot) : null });
426
494
  }
427
495
  if (!run.browsers.some((x) => x.role === 'host')) run.blocked ??= 'neither browser became the room\'s host';
428
496
  if (profile) {
@@ -471,8 +539,12 @@ export async function perfRun({ root = null, url, game, devices = ['computer', '
471
539
  if (!chrome) return { ok: false, command: 'perf', why: noChrome() };
472
540
  let puppeteer;
473
541
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'perf', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
474
- const alive = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(15_000) }).then((r) => r.ok).catch(() => false);
475
- if (!alive) return { ok: false, command: 'perf', why: `${base}/${game}/play does not answer: start the site (npm run dev, as a background task) or check the address` };
542
+ // One preflight from this process (lib/net.mjs `reachSite`): a name Node cannot look up, or no connection, is said as
543
+ // this computer's network (BLOCKED, nothing measured), with local testing offered, never as a site or a game that
544
+ // does not answer. `siteRefusal` is the one sentence every command gives for it.
545
+ const reach = await reachSite(base, { path: `/${game}/play`, timeout: 15_000 });
546
+ const refusal = siteRefusal(reach, { command: 'perf', play: `${base}/${game}/play` });
547
+ if (refusal) return refusal;
476
548
  const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '');
477
549
  const dir = resolve(out ?? join(root ?? process.cwd(), '.perf', game, stamp));
478
550
  mkdirSync(dir, { recursive: true });
@@ -526,12 +598,27 @@ export function metricsOfRun(run) {
526
598
  put('work.p50', b.work?.p50); put('work.p95', b.work?.p95); put('work.mean', b.work?.mean);
527
599
  put('busy', b.main?.busyPerFrame); put('script', b.main?.scriptMsPerS);
528
600
  put('heap', b.heap?.afterGcMb); put('heap.growth', b.heap?.gcGrowthMbPerMin);
529
- put('load.look', b.load?.lookMs); put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
601
+ put('load.look', b.load?.lookMs); put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.ready', b.load?.readyMs); put('load.gameKb', b.load?.gameKb); put('load.prePlayKb', b.load?.prePlay?.kb);
602
+ put('render.calls', b.render?.drawCalls?.median); put('render.triangles', b.render?.triangles?.median);
530
603
  put('net.msgsOut', b.net?.msgsOut); put('net.msgsIn', b.net?.msgsIn); put('net.kbOut', b.net?.kbOut); put('net.kbIn', b.net?.kbIn);
531
604
  }
532
605
  return out;
533
606
  }
534
607
 
608
+ /** Per device and role, the arrival mode the runs reported and the worst lateness of the game's own "ready". */
609
+ function arrivalOfRuns(runs) {
610
+ const by = new Map();
611
+ for (const r of runs) for (const b of r.browsers ?? []) {
612
+ const a = b.load?.arrival; if (!a) continue;
613
+ const k = `${r.device}.${b.role}`;
614
+ const row = by.get(k) ?? { device: r.device, role: b.role, mode: null, by: null, lateMs: null };
615
+ row.mode ??= a.mode; row.by ??= a.by;
616
+ if (Number.isFinite(a.lateMs)) row.lateMs = Math.max(row.lateMs ?? 0, a.lateMs);
617
+ by.set(k, row);
618
+ }
619
+ return [...by.values()];
620
+ }
621
+
535
622
  /** Medians of every metric over the runs that count (not blocked, not loaded), per metric. */
536
623
  export function summaryOf(runs) {
537
624
  const counted = runs.filter((r) => !r.blocked && !r.loaded);
@@ -539,7 +626,19 @@ export function summaryOf(runs) {
539
626
  for (const r of counted) for (const [k, v] of Object.entries(metricsOfRun(r))) (by[k] ??= []).push(v);
540
627
  const metrics = Object.fromEntries(Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([k, xs]) => [k, summarize(xs)]));
541
628
  const renderers = [...new Set(runs.map((r) => r.renderer).filter(Boolean))];
542
- return { v: 1, kind: 'homie-perf-summary', runs: runs.length, counted: counted.length, devices: [...new Set(runs.map((r) => r.device))], renderers, load: runs.map((r) => ({ device: r.device, before: r.load?.before?.load1 ?? null, after: r.load?.after?.load1 ?? null, busyPct: r.load?.busyPct ?? null, loaded: Boolean(r.loaded) })), metrics };
629
+ return { v: 1, kind: 'homie-perf-summary', runs: runs.length, counted: counted.length, devices: [...new Set(runs.map((r) => r.device))], renderers, arrival: arrivalOfRuns(counted), load: runs.map((r) => ({ device: r.device, before: r.load?.before?.load1 ?? null, after: r.load?.after?.load1 ?? null, busyPct: r.load?.busyPct ?? null, loaded: Boolean(r.loaded) })), metrics };
630
+ }
631
+
632
+ /**
633
+ * Beside control-ready, in the headline: the arrival mode and the game's own "ready". "the game said ready at 2400 ms
634
+ * (arrival auto: 1700 ms after the cover had lifted)" is the integration mistake NETPLAY.md section 21 names: a game
635
+ * that calls net.playable() and never said `arrival: 'game'`, so players see it before it is ready.
636
+ */
637
+ function arrivalWords(summary, device, role, readyMs) {
638
+ const a = (summary.arrival ?? []).find((x) => x.device === device && x.role === role);
639
+ if (!a?.mode && readyMs === undefined) return '';
640
+ const said = readyMs !== undefined ? `the game said ready at ${readyMs} ms` : 'the game never said ready itself';
641
+ return `, ${said} (arrival ${a?.mode ?? 'unknown'}${Number.isFinite(a?.lateMs) ? `: ${a.lateMs} ms after the cover had lifted` : ''})`;
543
642
  }
544
643
 
545
644
  /** A few medians for a person: frame time, the game's work per frame and time to playable, per device and role. */
@@ -549,7 +648,7 @@ function headlineOf(summary) {
549
648
  for (const role of ['host', 'replica']) {
550
649
  const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
551
650
  if (m('frame.p50') === undefined) continue;
552
- out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
651
+ out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable (control-ready) at ${m('load.playable') ?? '?'} ms${arrivalWords(summary, device, role, m('load.ready'))}; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB; fetched before playable (measured) ${m('load.prePlayKb') ?? '?'} KB; renderer ${m('render.calls') !== undefined ? `${m('render.calls')} draw calls, ${m('render.triangles') ?? '?'} triangles (measured while playing)` : 'cost not exposed by the game (not measured)'}`);
553
652
  }
554
653
  }
555
654
  return out;
@@ -564,7 +663,7 @@ const COMPRESSIBLE = new Set(['js', 'css', 'html', 'data', 'model']);
564
663
  /**
565
664
  * `homie-studio perf sizes <game>`: what a player downloads, from the built game (site/dist/games/<id>): every file,
566
665
  * raw and gzipped (what a server sends when the browser accepts it), the biggest first, and with a build that kept its
567
- * map (`build --maps`) which source modules make up the bundle. `source.json` (for remixers), `_landing/`, `hero/` and
666
+ * map (`build --maps`) which source modules make up the bundle. `_landing/`, `hero/` and
568
667
  * the cover (the landing page's) are listed apart: the game itself never loads them. What a browser really fetched is
569
668
  * in each run (`load`).
570
669
  *
@@ -581,8 +680,8 @@ export function perfSizes(root, game) {
581
680
  const files = listFiles(dir).map((f) => {
582
681
  const buf = readFileSync(join(dir, f));
583
682
  const kind = kindOf(f);
584
- // Never loaded by the game itself: the remix source, and the landing page's art (hero footage, the cover).
585
- return { path: f, kind, bytes: buf.length, gzip: COMPRESSIBLE.has(kind) ? gzipSync(buf, { level: 6 }).length : buf.length, apart: f === 'source.json' || f.startsWith('_landing/') || f.startsWith('hero/') || f === cover };
683
+ // Never loaded by the game itself: the landing page's art (hero footage, the cover).
684
+ return { path: f, kind, bytes: buf.length, gzip: COMPRESSIBLE.has(kind) ? gzipSync(buf, { level: 6 }).length : buf.length, apart: f === 'bundle.json' || f.startsWith('_landing/') || f.startsWith('hero/') || f === cover };
586
685
  });
587
686
  const game_ = files.filter((f) => !f.apart);
588
687
  const sum = (xs, k) => xs.reduce((s, x) => s + x[k], 0);
@@ -590,13 +689,14 @@ export function perfSizes(root, game) {
590
689
  for (const f of game_) { const k = (byKind[f.kind] ??= { files: 0, bytes: 0, gzip: 0 }); k.files++; k.bytes += f.bytes; k.gzip += f.gzip; }
591
690
  let modules = null;
592
691
  const mapDir = join(root, '.studio', 'maps', game);
593
- const main = join(dir, 'assets', 'main.js');
692
+ const bundle = bundleOf(dir) ?? 'assets/main.js';
693
+ const main = join(dir, bundle);
594
694
  if (existsSync(join(mapDir, 'meta.json')) && existsSync(main)) {
595
695
  try {
596
696
  const want = readFileSync(join(mapDir, 'main.js.sha256'), 'utf8').trim();
597
697
  if (want === sha256(readFileSync(main))) {
598
698
  const meta = JSON.parse(readFileSync(join(mapDir, 'meta.json'), 'utf8'));
599
- const outKey = Object.keys(meta.outputs ?? {}).find((k) => k.endsWith('main.js'));
699
+ const outKey = Object.keys(meta.outputs ?? {}).find((k) => k.endsWith(`/${basename(bundle)}`)) ?? Object.keys(meta.outputs ?? {}).find((k) => k.endsWith('main.js'));
600
700
  const inputs = Object.entries(meta.outputs?.[outKey]?.inputs ?? {}).map(([p, v]) => ({ module: p.replace(/^(\.\.\/)+/, '').replace(/^.*node_modules\//, 'node_modules/'), bytes: v.bytesInOutput })).sort((a, b) => b.bytes - a.bytes);
601
701
  modules = { total: inputs.reduce((s, x) => s + x.bytes, 0), top: inputs.slice(0, 12) };
602
702
  }
@@ -610,7 +710,7 @@ export function perfSizes(root, game) {
610
710
  js: { bytes: byKind.js?.bytes ?? 0, gzip: byKind.js?.gzip ?? 0 },
611
711
  byKind,
612
712
  biggest: [...game_].sort((a, b) => b.bytes - a.bytes).slice(0, 12).map(({ apart, ...f }) => (f.kind === 'js' && f.bytes >= 20 * 1024 ? { ...f, code: codeOf(join(dir, f.path)) } : f)),
613
- apart: files.filter((f) => f.apart).length ? { files: files.filter((f) => f.apart).length, bytes: sum(files.filter((f) => f.apart), 'bytes'), note: 'the remix source and the landing page\'s art (hero/, the cover): never loaded by the game' } : null,
713
+ apart: files.filter((f) => f.apart).length ? { files: files.filter((f) => f.apart).length, bytes: sum(files.filter((f) => f.apart), 'bytes'), note: 'the landing page\'s art (hero/, the cover): never loaded by the game' } : null,
614
714
  modules,
615
715
  };
616
716
  }
@@ -679,7 +779,7 @@ export function perfCompare(beforeDir, afterDir, { goal = DEFAULT_GOAL, guards =
679
779
  return [xs, ys];
680
780
  };
681
781
  const devices = [...new Set([...A.runs, ...B.runs].map((r) => r.device))];
682
- const unit = (k) => (k.startsWith('bytes.') ? ' B' : /\.load\.gameKb$/.test(k) ? ' KB' : /\.(frame|work)\.(p\d+|max|mean)$|\.busy$|\.load\./.test(k) ? ' ms' : /\.frame\.over\d+$/.test(k) ? '%' : /heap$/.test(k) ? ' MB' : /heap\.growth$/.test(k) ? ' MB/min' : /\.net\.kb/.test(k) ? ' KB/s' : /\.net\.msgs/.test(k) ? '/s' : /\.script$/.test(k) ? ' ms/s' : '');
782
+ const unit = (k) => (k.startsWith('bytes.') ? ' B' : /\.load\.(gameKb|prePlayKb)$/.test(k) ? ' KB' : /\.render\./.test(k) ? '' : /\.(frame|work)\.(p\d+|max|mean)$|\.busy$|\.load\./.test(k) ? ' ms' : /\.frame\.over\d+$/.test(k) ? '%' : /heap$/.test(k) ? ' MB' : /heap\.growth$/.test(k) ? ' MB/min' : /\.net\.kb/.test(k) ? ' KB/s' : /\.net\.msgs/.test(k) ? '/s' : /\.script$/.test(k) ? ' ms/s' : '');
683
783
  const one = (k, opts = {}) => {
684
784
  if (paired && !k.startsWith('bytes.')) { const [xs, ys] = pairedOf(k); if (xs.length >= 3) return { metric: k, ...judgePaired(xs, ys, { min, unit: unit(k), ...opts }) }; }
685
785
  return { metric: k, ...judge(a[k] ?? [], b[k] ?? [], { min, unit: unit(k), ...opts }) };
@@ -33,7 +33,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import { createRequire } from 'node:module';
34
34
  import { tmpdir } from 'node:os';
35
35
  import { join } from 'node:path';
36
- import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
36
+ import { chromeArgs, findChrome, isLoopbackUrl, noChrome } from './chrome.mjs';
37
+ import { reachSite, siteRefusal } from './net.mjs';
37
38
 
38
39
  const GPU = [
39
40
  ...chromeArgs(), '--autoplay-policy=no-user-gesture-required',
@@ -181,10 +182,36 @@ export function judgePresses(rows, presses, size, view) {
181
182
  return { presses: out.length, blocked, passed, ok: movedRows.length >= Math.ceil(out.length * 0.7) && passed >= Math.ceil(movedRows.length * 0.9) && out.every((x) => x.yawDeg === undefined || x.yawDeg < 10), wrongWay: movedRows.filter((x) => !x.ok).map((x) => x.dir), rows: out };
182
183
  }
183
184
 
185
+ /**
186
+ * THE BIG SCREEN'S ROW, from what was read off it. `f`: { probe (the game's probe answered), seat, hasBody, qr (a
187
+ * join QR is on the page), framesBefore, framesAfter, luma, loopback (the site is this computer's own address) }.
188
+ *
189
+ * The join QR is judged only where it can exist. The play page leaves the join card out on a loopback preview on
190
+ * purpose (a code for 127.0.0.1 brings no other phone in), so there its absence is `qr: 'not applicable'`, said in
191
+ * the row, and never a failure; on an address another phone can reach it is required. A local pass therefore says
192
+ * nothing about the live QR: that is checked against the deployed site.
193
+ */
194
+ export function judgeTv(f) {
195
+ const qrRequired = !f.loopback;
196
+ const drawing = (f.framesAfter ?? 0) > (f.framesBefore ?? 0);
197
+ const why = !f.probe ? 'no game probe on the big screen'
198
+ : f.seat !== null && f.seat !== undefined ? 'the big screen took a seat'
199
+ : f.hasBody ? 'the big screen reports a body of its own'
200
+ : qrRequired && !f.qr ? 'no join QR on the big screen'
201
+ : !drawing ? 'the big screen is not drawing (its frame counter did not move)'
202
+ : f.luma !== null && f.luma !== undefined && f.luma <= 6 ? 'the big screen picture is black'
203
+ : undefined;
204
+ return {
205
+ ok: !why, why,
206
+ qr: qrRequired ? Boolean(f.qr) : 'not applicable',
207
+ ...(qrRequired ? {} : { qrNote: `the join QR was not checked: this is a loopback preview, where the page hides the join card on purpose${f.qr ? ' (one was on the page all the same)' : ''}. Check the big screen on the deployed address before release: there the QR is required` }),
208
+ };
209
+ }
210
+
184
211
  /* ------------------------------------------------------------------ browsers */
185
212
 
186
213
  function descendants(pid) {
187
- const kids = (spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean).map(Number);
214
+ const kids = (spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' }).stdout ?? '').split('\n').filter(Boolean).map(Number);
188
215
  return kids.flatMap((k) => [k, ...descendants(k)]);
189
216
  }
190
217
 
@@ -428,11 +455,18 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
428
455
  if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
429
456
  tell(name, 'running');
430
457
  if (await alive()) return true;
431
- row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
458
+ // Why, from this process's own preflight (lib/net.mjs): a public address this computer's Node cannot look up is
459
+ // the network, with local testing offered; only this computer's own address is "is the dev server running?".
460
+ const reach = await reachSite(base, { path: `/${game}/play`, timeout: 8000 });
461
+ row(name, false, { why: !reach.ok && reach.preflight !== 'local' && reach.preflight !== 'site' ? `${reach.why} ${reach.instead ?? ''}`.trim() : `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
432
462
  return false;
433
463
  };
434
464
  if (!(await alive())) {
435
- const result = { ok: false, command: 'port check', game, url: base, why: `${base}/${game}/play does not answer: start the site first (npm run dev, kept running in the background) or check the address` };
465
+ // The same classification and sentence as check, perf and shoot (lib/net.mjs siteRefusal): a name this computer
466
+ // cannot look up is BLOCKED with local testing offered, never "does not answer: start the site".
467
+ const reach = await reachSite(base, { path: `/${game}/play`, timeout: 8000 });
468
+ const refusal = siteRefusal(reach, { command: 'port check', play: `${base}/${game}/play` }) ?? { ok: false, command: 'port check', why: `${base}/${game}/play does not answer: start the site first (npm run dev, kept running in the background) or check the address` };
469
+ const result = { ...refusal, game, url: base };
436
470
  writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
437
471
  return result;
438
472
  }
@@ -633,8 +667,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
633
667
  const png = await T(h.page.screenshot({ path: join(out, 'tv.png') }), 15_000);
634
668
  const lum = png ? await lumaOf(h) : null;
635
669
  const me = (await rowsSince(h, 0)).some(finite);
636
- const ok = Boolean(i2 && s && s.seat === null && !me && qr && (i2.frames ?? 0) > (i?.frames ?? 0) && (lum === null || lum > 6));
637
- row('tv', ok, { role: i2?.role, seat: s?.seat ?? null, hasBody: me, qr, frames: i2?.frames ?? null, luma: lum, why: !i2 ? 'no game probe on the big screen' : s?.seat !== null ? 'the big screen took a seat' : me ? 'the big screen reports a body of its own' : !qr ? 'no join QR on the big screen' : lum !== null && lum <= 6 ? 'the big screen picture is black' : undefined });
670
+ const tv = judgeTv({ probe: Boolean(i2 && s), seat: s?.seat ?? null, hasBody: me, qr, framesBefore: i?.frames ?? 0, framesAfter: i2?.frames ?? 0, luma: lum, loopback: isLoopbackUrl(base) });
671
+ row('tv', tv.ok, { role: i2?.role, seat: s?.seat ?? null, hasBody: me, qr: tv.qr, ...(tv.qrNote ? { qrNote: tv.qrNote } : {}), frames: i2?.frames ?? null, luma: lum, why: tv.why });
638
672
  collect(h); await close(h);
639
673
  } catch (e) {
640
674
  row('tv', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `homie-studio preview <id> [--port 8788]` — ONE BUILT GAME'S FILES, served as they are, and nothing else.
3
+ *
4
+ * `dev` is the whole site: Wrangler, a local Durable Object for the rooms, D1. A capture script, a perf run or a
5
+ * headless screenshot of one game needs none of that, only the game's built files at an address. Before this, the
6
+ * way to get one was a static server started by hand inside site/dist/games/<id>, which the next build deleted the
7
+ * folder from under.
8
+ *
9
+ * This serves site/dist/games/<id>/ at `/` (index.html, its hashed bundle and chunks, its public files), reading
10
+ * each file when it is asked for, by its path: a build that finishes while it runs is simply what the next request
11
+ * gets (lib/stage.mjs puts a build in place without removing the folder). It is the game's page as the build made
12
+ * it, with no HOMIE_NET: the netplay helper finds no room and the game plays offline, alone with its bots, which is
13
+ * what a capture of the game itself wants. Rooms, the play page's buttons and anything multiplayer are `dev`'s.
14
+ *
15
+ * It listens on this computer only (127.0.0.1), writes nothing, and stops with the process (Ctrl-C, or a signal).
16
+ */
17
+ import { createServer } from 'node:http';
18
+ import { existsSync, readFileSync, statSync } from 'node:fs';
19
+ import { extname, join, normalize, resolve, sep } from 'node:path';
20
+
21
+ export const PREVIEW_PORT = 8788;
22
+
23
+ const TYPES = {
24
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
25
+ '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml',
26
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',
27
+ '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.mp4': 'video/mp4', '.webm': 'video/webm',
28
+ '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.bin': 'application/octet-stream', '.hdr': 'application/octet-stream', '.ktx2': 'image/ktx2',
29
+ '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
30
+ };
31
+
32
+ /** The file a request path names inside the game's built folder, or null (outside it, or not a file). */
33
+ function fileFor(dir, pathname) {
34
+ let rel;
35
+ try { rel = decodeURIComponent(pathname); } catch { return null; }
36
+ if (rel.includes('\0')) return null;
37
+ const abs = resolve(dir, `.${normalize(`/${rel}`)}`);
38
+ if (abs !== dir && !abs.startsWith(`${dir}${sep}`)) return null;
39
+ for (const f of [abs, join(abs, 'index.html')]) {
40
+ try { if (statSync(f).isFile()) return f; } catch { /* next */ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Start it. Resolves { ok, server, url, port, game, dir } once it listens; `server.close()` stops it. A port that is
47
+ * taken is not fought over: unless the port was asked for by name (`strict`), the next free one is used and said.
48
+ */
49
+ export function previewServer(root, id, { port = PREVIEW_PORT, strict = false } = {}) {
50
+ const dir = resolve(root, 'site', 'dist', 'games', String(id ?? ''));
51
+ if (!id || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(id))) return Promise.resolve({ ok: false, command: 'preview', why: 'name the game: homie-studio preview <id>' });
52
+ if (!existsSync(join(dir, 'index.html'))) return Promise.resolve({ ok: false, command: 'preview', why: `no build of ${id} in site/dist: run \`npx --no-install homie-studio build ${id}\` first` });
53
+ const server = createServer((req, res) => {
54
+ const url = new URL(req.url ?? '/', 'http://preview.local');
55
+ const headers = { 'cache-control': 'no-store', 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
56
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, headers).end(); return; }
57
+ const file = fileFor(dir, url.pathname);
58
+ if (!file) { res.writeHead(404, { ...headers, 'content-type': 'text/plain; charset=utf-8' }).end(`not in the build of ${id}: ${url.pathname}\n`); return; }
59
+ let body;
60
+ try { body = readFileSync(file); } catch { res.writeHead(404, headers).end(); return; }
61
+ res.writeHead(200, { ...headers, 'content-type': TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream', 'content-length': body.length });
62
+ res.end(req.method === 'HEAD' ? undefined : body);
63
+ });
64
+ return new Promise((done) => {
65
+ const listen = (p, again) => {
66
+ server.once('error', (error) => {
67
+ if (error?.code === 'EADDRINUSE' && again) { listen(0, false); return; }
68
+ done({ ok: false, command: 'preview', why: error?.code === 'EADDRINUSE' ? `port ${p} is in use: give another with --port` : String(error?.message ?? error) });
69
+ });
70
+ server.listen(p, '127.0.0.1', () => {
71
+ const at = server.address().port;
72
+ done({ ok: true, command: 'preview', server, port: at, url: `http://127.0.0.1:${at}/`, game: id, dir });
73
+ });
74
+ };
75
+ listen(Number(port) || 0, !strict);
76
+ });
77
+ }