@homie-rocks/studio 0.30.2 → 0.31.1
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 +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- 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)
|
|
22
|
-
* the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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. `
|
|
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
|
|
585
|
-
return { path: f, kind, bytes: buf.length, gzip: COMPRESSIBLE.has(kind) ? gzipSync(buf, { level: 6 }).length : buf.length, apart: f === '
|
|
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
|
|
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
|
|
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 }) };
|
package/lib/port-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
637
|
-
row('tv', ok, { role: i2?.role, seat: s?.seat ?? null, hasBody: me, qr
|
|
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]}` });
|
package/lib/preview.mjs
ADDED
|
@@ -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
|
+
}
|