@homie-rocks/studio 0.58.8 → 0.58.11

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 CHANGED
@@ -17,6 +17,38 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.58.11] - 2026-10-19
21
+
22
+ **Plugin 0.58.5**
23
+
24
+ Host a studio on Workers for Platforms with a private preview, check it with a browser in the cloud, and prepare a studio for a move with an owner-controlled read-only switch.
25
+
26
+ ### Added
27
+
28
+ - A studio can deploy to a Workers for Platforms dispatch namespace: `cloudflare.dispatchNamespace`, `worker`, `d1Id` and `domain` in studio.json name the preprovisioned Worker, and `homie-studio deploy` sends the build there with Wrangler's own `--dispatch-namespace`. `cloudflare.preview` names a second Worker and database, and `homie-studio deploy --preview` sends the same build to it. A platform that hosts studios this way (Homie Managed) enforces the target at its API boundary.
29
+ - Every command and skill that opens a browser (check, playtest, look, perf, shoot, lab check, port check, crowd load, 3D renders, art captures and video takes) can use a browser in the cloud: with `HOMIE_BROWSER_WS_ENDPOINT` set to a CDP WebSocket address (Cloudflare Browser Run, for example), they connect to it instead of starting Chrome on this computer. A platform target leaves queues and cron triggers to the platform.
30
+ - `homie-studio drain on|off|status` pauses new room admissions without deploying or closing existing sockets. A move waits for empty rooms and delivered records before freezing the source.
31
+ - Managed media bindings preserve streamed media, range requests and large player saves.
32
+ - A read-only switch for a studio: `homie-studio read-only on|off|status` (and the local MCP tool `studio_read_only`) freezes the live studio for a few minutes, for a move to another host or maintenance, without losing anything. It sets the Worker secret `STUDIO_READ_ONLY` with the studio's own Cloudflare sign-in, as a new Worker version. While on, changes answer 503 with `Retry-After` and `x-studio-read-only: 1`; pages, games, live rooms and the Stripe webhook keep working, D1 takes no other write, room records wait in each room, scheduled and queued work waits, and `/api/games` reports `readOnly`.
33
+
34
+ ### Fixed
35
+
36
+ - Standalone apps can read an app-record write's read-only refusal; other write routes remain restricted to the studio's own origin.
37
+
38
+ ## [0.58.9] - 2026-10-19
39
+
40
+ **Plugin 0.58.3**
41
+
42
+ Build games from shared parts while keeping server rules deterministic.
43
+
44
+ ### Fixed
45
+
46
+ - Rules can import registered studio parts and the re-exports created by `part_new`. Every dependency receives the existing rules checks, including filesystem boundary checks.
47
+
48
+ ### Changed
49
+
50
+ - Game guides require finding, adding and actually using fitting parts before writing replacements, with source credits and concrete gaps recorded in Built from.
51
+
20
52
  ## [0.58.8] - 2026-10-19
21
53
 
22
54
  **Plugin 0.58.3**
@@ -165,6 +197,9 @@ A room nobody is playing rests, and the next touch wakes it with that input stil
165
197
  - Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
166
198
  going while people are present. A lasting world never rests.
167
199
 
200
+ ### Added
201
+
202
+
168
203
  ## [0.58.0] - 2026-10-16
169
204
 
170
205
  **Plugin 0.58.0**
package/README.md CHANGED
@@ -752,6 +752,40 @@ that, with when it ends.
752
752
  open it), the command says "network preflight failed", not that the site or a game is broken, and offers the local
753
753
  dev site instead.
754
754
 
755
+ ## Read-only for a few minutes: `homie-studio read-only`
756
+
757
+ Before moving a studio to another host, or during maintenance, the owner freezes it from their own computer and
758
+ opens it again when the work is done. Nothing is lost on the way:
759
+
760
+ ```sh
761
+ npx homie-studio read-only on # every change is refused for now; the site stays up
762
+ npx homie-studio read-only status # what Cloudflare has, and what the live site says
763
+ npx homie-studio read-only off # open again
764
+ ```
765
+
766
+ The switch is the Worker secret `STUDIO_READ_ONLY` (`1`), set and deleted with the studio's own Wrangler sign-in.
767
+ Cloudflare applies a secret as a new version of the live Worker within seconds, with no build or deploy, and a deploy
768
+ made while it is on keeps it on; like any new version, it reconnects live rooms once. The local MCP tool
769
+ `studio_read_only` does the same for an AI. While it is on:
770
+
771
+ - every request other than `GET`, `HEAD` and `OPTIONS` answers `503` with `Retry-After: 120`,
772
+ `x-studio-read-only: 1` and `{"ok":false,"error":"read-only","message":"…"}`. Nothing it carried was applied, so
773
+ the caller sends it again later;
774
+ - every other answer carries `x-studio-read-only: 1` too, a page shows a short "read-only for a few minutes"
775
+ notice, and `/api/games` says `"readOnly": true` (and `false` when open), so a tool checks the switch without
776
+ sending a write;
777
+ - D1 refuses writes, so a read that would also write (a visit counted, a sign-in link redeemed) writes nothing; a
778
+ page that needed its write answers the same 503;
779
+ - Stripe's webhook (`POST /api/shop/hook`) and the queued reconciliation of its events keep the whole database:
780
+ payments already made are recorded and fulfilled;
781
+ - live rooms keep playing and new ones still open (matchmaking and sockets are `GET`s, a room's state is its own
782
+ storage), but no room writes to D1: round records and function events wait in the room's outbox and are written
783
+ once the studio is open, counters with the room's next report;
784
+ - scheduled work skips its tick, apart from the recovery of Stripe events (an owner function due in that minute does
785
+ not run for it); queued work other than Stripe events is retried after the window.
786
+
787
+ `worker/read-only.mjs` is the whole behaviour.
788
+
755
789
  ## A trailer: `homie-studio trailer`
756
790
 
757
791
  ```sh
@@ -106,6 +106,12 @@
106
106
  * A command that changes something outside this computer (deploy, publish,
107
107
  * storage add, the office, …) stops at a flag it does not know: nothing is sent.
108
108
  * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
109
+ * homie-studio read-only [on|off|status] [--url <site>]
110
+ * (freeze the live studio for a few minutes, e.g. while moving it or for
111
+ * maintenance: the Worker secret STUDIO_READ_ONLY, applied at once with no
112
+ * deploy. While on, every change answers 503 with Retry-After and
113
+ * x-studio-read-only: 1; pages, games, live rooms and the Stripe webhook keep
114
+ * working; /api/games says readOnly. worker/read-only.mjs says exactly what)
109
115
  * homie-studio media list (every song and video page, where each file is served from, what moves to R2)
110
116
  * homie-studio media move [<file>...] [--dry-run] [--verify]
111
117
  * (with storage: the big files of published songs and videos into R2, each
@@ -529,6 +535,13 @@ function print(result) {
529
535
  ...(result.parts && partsPlanLines(result.parts).length ? ['', ...partsPlanLines(result.parts)] : []),
530
536
  ...(result.standalone?.length ? ['', ...result.standalone] : []));
531
537
  break;
538
+ case 'drain on':
539
+ case 'drain off':
540
+ case 'drain status':
541
+ case 'read-only on':
542
+ case 'read-only off':
543
+ case 'read-only status':
544
+ lines.push(result.message); break;
532
545
  case 'storage add':
533
546
  lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
534
547
  break;
@@ -827,8 +840,10 @@ function print(result) {
827
840
  */
828
841
  const OUTWARD_FLAGS = {
829
842
  publish: ['homie', 'site', 'before'],
830
- deploy: ['plan', 'ci', 'homie', 'own-route'],
843
+ deploy: ['plan', 'ci', 'homie', 'own-route', 'preview'],
831
844
  'storage add': [],
845
+ 'drain': ['url'],
846
+ 'read-only': ['url'],
832
847
  'media put': ['as'],
833
848
  'media move': ['dry-run', 'plan', 'verify'],
834
849
  'setup attach': ['homie', 'client'],
@@ -917,6 +932,8 @@ async function main() {
917
932
  if (['build', 'check', 'upgrade'].includes(cmd)) {
918
933
  for (const line of (await import('../lib/authoring-guides.mjs')).authoringWords({ kind: ['app', 'business', 'cause'].includes(readStudio(root).kind) || listExperiences(root).some(g => g.kind === 'app') ? 'app' : 'game' })) process.stderr.write(`${line}\n`);
919
934
  }
935
+ if (cmd === 'drain') return (await import('../lib/move-drain.mjs')).drainCommand(root, sub ?? 'status', { url: flags.get('url') ?? null });
936
+ if (cmd === 'read-only') return (await import('../lib/read-only.mjs')).readOnlyCommand(root, sub ?? 'status', { url: flags.get('url') ?? null });
920
937
  if (cmd === 'events') return (await import('../lib/events-cli.mjs')).eventsCommand(root, sub, positional, flags, { log });
921
938
  if (cmd === 'function' || cmd === 'functions') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
922
939
  if (cmd === 'connector') return (await import('../lib/connector-token.mjs')).connectorToken(root,{url:flags.get('url'),tools:typeof flags.get('tools')==='string'?flags.get('tools').split(','):[],out:flags.get('out'),days:flags.has('days')?Number(flags.get('days')):30,revoke:flags.get('revoke')});
@@ -1056,7 +1073,7 @@ async function main() {
1056
1073
  // Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
1057
1074
  if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
1058
1075
  // What the deploy means for standalone copies already made (a changed netplay version), read before it runs.
1059
- if (cmd === 'deploy') { const copies = standaloneDeployNotes(root); return tracked(root, 'deploy', async () => { const r = await deploy(root, { log, homie: flags.get('homie'), ownRoute: flags.has('own-route') }); return r?.ok ? { ...r, standalone: copies } : r; }, 'deploy'); }
1076
+ if (cmd === 'deploy') { const copies = standaloneDeployNotes(root); return tracked(root, 'deploy', async () => { const r = await deploy(root, { log, homie: flags.get('homie'), ownRoute: flags.has('own-route'), preview: flags.has('preview') }); return r?.ok ? { ...r, standalone: copies } : r; }, 'deploy'); }
1060
1077
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
1061
1078
  if (cmd === 'storage') {
1062
1079
  const cf = readStudio(root).cloudflare ?? {};
@@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statS
6
6
  import { dirname, extname, join, relative, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { experienceDir, experienceJson, isServerRules, SLUG, checkBudget, findStudio, readBudget, readJson, setBudget, writeJson } from '../../music/scripts/lib/studio.mjs';
9
- import { GPU_FLAGS, chromePath, htmlToPng, loadPuppeteer } from '../../video/scripts/lib/browser.mjs';
9
+ import { GPU_FLAGS, chromePath, htmlToPng, launchChrome, loadPuppeteer, remoteBrowserEndpoint } from '../../video/scripts/lib/browser.mjs';
10
10
 
11
11
  import { decode, stats } from '../../playtest/scripts/lib/pixels.mjs';
12
12
 
@@ -64,8 +64,9 @@ async function frame(root) {
64
64
  const seconds = Math.max(4, Math.min(60, Number(flags.get('seconds') ?? 12)));
65
65
  const dir = artDir(root, String(flags.get('slug') ?? 'frames'));
66
66
  const puppeteer = loadPuppeteer(root); const exe = chromePath();
67
- if (!puppeteer || !exe) throw new Error(!exe ? 'no Chrome found (set CHROME_PATH)' : 'puppeteer-core is missing (npm install in the studio)');
68
- const browser = await puppeteer.launch({ executablePath: exe, headless: true, timeout: 150_000, args: [...GPU_FLAGS, '--mute-audio', '--window-size=1920,1080', '--hide-scrollbars', '--force-color-profile=srgb', '--no-first-run'] });
67
+ const cloud = remoteBrowserEndpoint();
68
+ if (!puppeteer || (!exe && !cloud)) throw new Error(!exe && !cloud ? 'no Chrome found (set CHROME_PATH)' : 'puppeteer-core is missing (npm install in the studio)');
69
+ const browser = await launchChrome(puppeteer, { executablePath: exe, headless: true, timeout: 150_000, args: [...GPU_FLAGS, '--mute-audio', '--window-size=1920,1080', '--hide-scrollbars', '--force-color-profile=srgb', '--no-first-run'] });
69
70
  const shots = [];
70
71
  try {
71
72
  const page = await browser.newPage();
@@ -6,6 +6,8 @@ A rules game has `src/rules.ts`, `src/move.ts`, `src/view.ts`, `game.json`,
6
6
  from the same package. Import `move` into the rules. Pictures and controls belong in
7
7
  view.ts; shared movement belongs in move.ts; all game decisions belong in rules.ts.
8
8
 
9
+ Rules may import a deterministic part already added to this studio with `@parts/<host>/<id>` (or an own part with `@parts/<id>`). The re-export created by `part_new` works too. Every imported module is checked by the same rules guard; browser-only audio, UI and control parts belong in the view. Parts cannot import arbitrary files outside this game and registered parts.
10
+
9
11
  ```ts
10
12
  import { defineRules, f } from '@homie-rocks/studio/rules';
11
13
  import { move } from './move';
@@ -46,6 +46,8 @@ Cloudflare or physical-phone capacity guarantees.
46
46
  ## Start it
47
47
 
48
48
  - **Look for pieces before writing them.** Games build on each other by sharing parts: a creature from one game,
49
+ Search by system (touch controls, feedback, sound, scoring, title and results UI, rooms), not only by genre. Add a fitting result with `part_add` before implementing a replacement; import and use it in the game. A starter or npm package is not a catalog search. Record part version, source game/studio, licence and integration in CODEX **Built from**, or the search and concrete gap if nothing fits. In managed studios these same tools work; wait for the committed preview through `build_progress` after adding a part.
50
+
49
51
  a level from another, a bot brain from a third. For each system the game needs (camera, movement, bots, pickups,
50
52
  effects, sound, UI, environment), before writing it from scratch:
51
53
  - check the `@homie-rocks/*` packages (npm) for the general mechanism: camera, input, audio, effects;
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url';
34
34
  import { experienceDir, experienceJson, findStudio, readJson } from '../../music/scripts/lib/studio.mjs';
35
35
  import { measure, sheetPng, warnings } from '../../sound/scripts/lib/measure.mjs';
36
36
  import { wavBytes } from '../../sound/scripts/lib/synth.mjs';
37
- import { GPU_FLAGS, chromePath, loadPuppeteer } from '../../video/scripts/lib/browser.mjs';
37
+ import { GPU_FLAGS, chromePath, launchChrome, loadPuppeteer, remoteBrowserEndpoint } from '../../video/scripts/lib/browser.mjs';
38
38
  import { finish, within } from './lib/exit.mjs';
39
39
  import { actionProfile, activePlay, connectionNote, contextOf, describeActions, describeState, judgeFirst, judgeMove, judgeScores, judgeUi, oppositeOf, shellOverlaps, EXTRA_NAMES, readPort, UI_CAVEAT, pairMismatch, readinessOf, renderCost, reportMd, reviewLine, stateOf, weakest, REVIEW_KINDS } from './lib/judge.mjs';
40
40
  import { decode, motion, stats, uiCover } from './lib/pixels.mjs';
@@ -72,7 +72,7 @@ const open = [];
72
72
  async function launch(device, url, { tap = false, autoplay = false } = {}) {
73
73
  const vp = DEVICES[device];
74
74
  const profile = mkdtempSync(join(tmpdir(), 'homie-playtest-'));
75
- const browser = await puppeteer.launch({
75
+ const browser = await launchChrome(puppeteer, {
76
76
  executablePath: EXE, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 120_000, // 150 s to start on a loaded computer
77
77
  args: [...GPU_FLAGS, '--mute-audio', `--window-size=${vp.width},${vp.height}`, '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb', ...(autoplay ? ['--autoplay-policy=no-user-gesture-required'] : [])],
78
78
  });
@@ -650,10 +650,10 @@ async function run() {
650
650
  const text = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
651
651
  if (!/^\.playtest\/?$/m.test(text)) appendFileSync(gi, `${text.endsWith('\n') || !text ? '' : '\n'}.playtest/\n`);
652
652
  }
653
- try { const f = statfsSync(out); if ((f.bavail * f.bsize) / 1e9 < 10) throw new Error('under 10 GB free on this disk: free some space before a playtest'); } catch (e) { if (/GB free/.test(e.message)) throw e; }
653
+ try { const f = statfsSync(out), minimumGB = remoteBrowserEndpoint() ? 1 : 10; if ((f.bavail * f.bsize) / 1e9 < minimumGB) throw new Error(`under ${minimumGB} GB free on this disk: free some space before a playtest`); } catch (e) { if (/GB free/.test(e.message)) throw e; }
654
654
  EXE = chromePath();
655
655
  puppeteer = loadPuppeteer(root);
656
- if (!EXE || !puppeteer) throw new Error(!EXE ? 'no Chrome found (set CHROME_PATH to a Chrome or Chromium)' : 'puppeteer-core is not installed (it comes with @homie-rocks/studio: npm install in the studio)');
656
+ if ((!EXE && !remoteBrowserEndpoint()) || !puppeteer) throw new Error(!EXE && !remoteBrowserEndpoint() ? 'no Chrome found (set CHROME_PATH to a Chrome or Chromium)' : 'puppeteer-core is not installed (it comes with @homie-rocks/studio: npm install in the studio)');
657
657
  const gameJson = root ? readJson(experienceJson(experienceDir(root, game)), {}) : {};
658
658
  const isApp = root && experienceJson(experienceDir(root, game)).endsWith('/app.json');
659
659
  const started = Date.now();
@@ -56,12 +56,12 @@ export function loadPuppeteer(root) {
56
56
  /** Launch; returns { browser, close } where close() also deletes the throwaway profile. */
57
57
  export async function launch(root, { width = 1920, height = 1080, extra = [] } = {}) {
58
58
  const exe = chromePath();
59
- if (!exe) throw new Error('no Chrome found (set CHROME_PATH to a Chrome or Chromium; on Linux, `npx --no-install homie-studio chrome install` in a studio fetches Chrome for Testing)');
59
+ if (!exe && !remoteBrowserEndpoint()) throw new Error('no Chrome found (set CHROME_PATH to a Chrome or Chromium; on Linux, `npx --no-install homie-studio chrome install` in a studio fetches Chrome for Testing)');
60
60
  const puppeteer = loadPuppeteer(root);
61
61
  if (!puppeteer) throw new Error('puppeteer-core is not installed (it comes with @homie-rocks/studio: run npm install in the studio)');
62
62
  const profile = mkdtempSync(join(tmpdir(), 'homie-video-'));
63
63
  // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
64
- const browser = await puppeteer.launch({
64
+ const browser = await launchChrome(puppeteer, {
65
65
  executablePath: exe, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
66
66
  args: [...GPU_FLAGS, `--window-size=${width},${height}`, '--hide-scrollbars', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--force-color-profile=srgb', ...extra],
67
67
  });
@@ -85,3 +85,24 @@ export async function htmlToPng(root, html, out, { width, height }) {
85
85
  } finally { await close(); }
86
86
  return out;
87
87
  }
88
+
89
+ /** Optional CDP endpoint for a browser in the cloud (Cloudflare Browser Run, CI): HOMIE_BROWSER_WS_ENDPOINT.
90
+ * https://developers.cloudflare.com/browser-run/cdp/puppeteer/ — each connection is its own browser session. */
91
+ export function remoteBrowserEndpoint(env = process.env) {
92
+ if (!env.HOMIE_BROWSER_WS_ENDPOINT) return null;
93
+ const endpoint = new URL(env.HOMIE_BROWSER_WS_ENDPOINT);
94
+ if (!['ws:', 'wss:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) {
95
+ throw new Error('HOMIE_BROWSER_WS_ENDPOINT must be a WebSocket URL without embedded credentials');
96
+ }
97
+ return endpoint.href;
98
+ }
99
+
100
+ /** Puppeteer's own CDP connection when a cloud browser is set; otherwise the usual local launch. */
101
+ export function launchChrome(puppeteer, options, { env = process.env } = {}) {
102
+ const browserWSEndpoint = remoteBrowserEndpoint(env);
103
+ if (!browserWSEndpoint) return puppeteer.launch(options);
104
+ // A cloud browser has no window: the size a local launch asks for with --window-size becomes the page's viewport.
105
+ const size = (options.args ?? []).map((a) => /^--window-size=(\d+),(\d+)$/.exec(a)).find(Boolean);
106
+ return puppeteer.connect({ browserWSEndpoint, protocolTimeout: options.protocolTimeout ?? 180_000,
107
+ defaultViewport: size ? { width: Number(size[1]), height: Number(size[2]) } : null });
108
+ }
package/lib/check.mjs CHANGED
@@ -33,7 +33,7 @@ import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps, primaryNeedSurfac
33
33
  import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
34
34
  import { cpus, loadavg, tmpdir } from 'node:os';
35
35
  import { join } from 'node:path';
36
- import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
36
+ import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome, remoteBrowserEndpoint, launchChrome } from './chrome.mjs';
37
37
  import { reachSite, siteRefusal } from './net.mjs';
38
38
 
39
39
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -156,7 +156,7 @@ export async function check({ webglFailure = false, isolate = true, root = null,
156
156
  } catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
157
157
  }
158
158
  const chrome = findChrome();
159
- if (!chrome) return { ok: false, command: 'check', why: noChrome() };
159
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'check', why: noChrome() };
160
160
  let puppeteer;
161
161
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
162
162
  if (appMeta) return checkApp({ webglFailure, isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
@@ -193,7 +193,7 @@ export async function check({ webglFailure = false, isolate = true, root = null,
193
193
  for (const kind of kinds) {
194
194
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
195
195
  profiles.push(profile);
196
- const browser = await puppeteer.launch({
196
+ const browser = await launchChrome(puppeteer, {
197
197
  executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
198
198
  args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
199
199
  });
@@ -394,7 +394,7 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
394
394
  }
395
395
  return remoteCookies.get(key);
396
396
  };
397
- if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
397
+ if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await launchChrome(puppeteer, { executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
398
398
  const openSurface = async (name, role) => {
399
399
  const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
400
400
  if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
package/lib/chrome.mjs CHANGED
@@ -135,3 +135,26 @@ export async function measureFrames(page, ms = 2000) {
135
135
  ]);
136
136
  } catch { return { fps: null, renderer: null }; }
137
137
  }
138
+
139
+ /** Optional CDP endpoint for CI and cloud browsers, including Cloudflare Browser Run.
140
+ * Each connection must acquire its own browser session; the caller closes it as usual.
141
+ * Cloudflare: https://developers.cloudflare.com/browser-run/cdp/puppeteer/
142
+ */
143
+ export function remoteBrowserEndpoint(env = process.env) {
144
+ if (!env.HOMIE_BROWSER_WS_ENDPOINT) return null;
145
+ const endpoint = new URL(env.HOMIE_BROWSER_WS_ENDPOINT);
146
+ if (!['ws:', 'wss:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) {
147
+ throw new Error('HOMIE_BROWSER_WS_ENDPOINT must be a WebSocket URL without embedded credentials');
148
+ }
149
+ return endpoint.href;
150
+ }
151
+
152
+ /** Puppeteer's own CDP connection; local checks keep their normal Chrome launch. */
153
+ export function launchChrome(puppeteer, options, { env = process.env } = {}) {
154
+ const browserWSEndpoint = remoteBrowserEndpoint(env);
155
+ if (!browserWSEndpoint) return puppeteer.launch(options);
156
+ // A cloud browser has no window: the size a local launch asks for with --window-size becomes the page's viewport.
157
+ const size = (options.args ?? []).map((a) => /^--window-size=(\d+),(\d+)$/.exec(a)).find(Boolean);
158
+ return puppeteer.connect({ browserWSEndpoint, protocolTimeout: options.protocolTimeout ?? 180_000,
159
+ defaultViewport: size ? { width: Number(size[1]), height: Number(size[2]) } : null });
160
+ }
@@ -1,3 +1,4 @@
1
+ import {dispatchTarget,dispatchArgs,targetConfig} from './dispatch-target.mjs';
1
2
  import {ensureStudioSigningKey} from './studios-keys.mjs';
2
3
  import {redactText} from './redact.mjs';
3
4
  import {enableReadReplication} from './read-replication.mjs';
@@ -73,10 +74,12 @@ export function wranglerBin(root) {
73
74
  }
74
75
 
75
76
  /** Wrangler from the studio's own node_modules, using Cloudflare's own sign-in. */
76
- export function runner(root, env) {
77
+ export function runner(root, env, { preview = false } = {}) {
77
78
  const bin = wranglerBin(root);
79
+ const target = dispatchTarget(readStudio(root), { preview });
78
80
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
79
81
  return (args, { cwd = workerDir(root), input, timeout = 10 * 60_000 } = {}) => {
82
+ args = dispatchArgs(args, target, target && targetConfig(root, target));
80
83
  const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
81
84
  if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
82
85
  const res = spawnSync(bin, args, {
@@ -278,8 +281,12 @@ export async function deploy(root, opts = {}) {
278
281
  try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
279
282
  }
280
283
 
281
- async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRoute = false } = {}) {
284
+ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRoute = false, preview = false } = {}) {
282
285
  const studio = readStudio(root);
286
+ if (dispatchTarget(studio, { preview })) {
287
+ await build(root, { log, deploy: true });
288
+ return ciDeploy(root, { log, preview });
289
+ }
283
290
  const cf = { r2: null, created: [], ...studio.cloudflare };
284
291
  const created = new Set(cf.created ?? []);
285
292
  // A wildcard or catch-all route in the studio's own config would hand other hostnames of the zone to this Worker:
@@ -636,8 +643,9 @@ export function configNames(root) {
636
643
  * nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
637
644
  * creates the one the binding names while it deploys, and the migrations run right after.
638
645
  */
639
- export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
640
- const w = wrangler ?? runner(root, {});
646
+ export async function ciDeploy(root, { log = () => {}, wrangler = null, preview = false } = {}) {
647
+ const target = dispatchTarget(readStudio(root), { preview });
648
+ const w = wrangler ?? runner(root, {}, { preview });
641
649
  refreshWorkerConfig(root);
642
650
  const names = configNames(root);
643
651
  const steps = [];
@@ -645,10 +653,11 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
645
653
  const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
646
654
  if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
647
655
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
648
- try {await ensureBackgroundQueues(root,w);} catch(error) {return refuse(error.message);}
656
+ // A platform target's queues are the platform's (lib/dispatch-target.mjs).
657
+ if (!target) { try {await ensureBackgroundQueues(root,w);} catch(error) {return refuse(error.message);} }
649
658
  const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
650
659
  let migrate = apply();
651
- const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
660
+ const first = !target && migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
652
661
  if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
653
662
  if (!first) step('D1 migrations applied');
654
663
  const register=()=>w(['d1','execute',names.binding,'--remote','--command',functionDeploymentSQL(root)]);
@@ -680,8 +689,9 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
680
689
  // The live address, from what Wrangler printed (a custom domain in studio.json wins).
681
690
  const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
682
691
  let url = null;
683
- try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
684
- const claim = url ? await warmClaim(url) : null;
692
+ try { url = target ? `https://${target.domain}` : siteUrl(root) ?? workersDev; } catch { url = workersDev; }
693
+ // A private preview is never claimed in the directory.
694
+ const claim = url && !target?.preview ? await warmClaim(url) : null;
685
695
  step(claim ? (listedHere(root,{site:url})?'the site is listed':'the site is claimed, not listed') : 'no directory claim yet (the site claims itself when the directory first reads it)');
686
696
  let games = [];
687
697
  try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
@@ -7,6 +7,7 @@ import { resolve } from 'node:path';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
9
9
  import puppeteer from 'puppeteer-core';
10
+ import { launchChrome } from './chrome.mjs';
10
11
  import { Histogram } from './crowd-worker.mjs';
11
12
  const opts = {};
12
13
  const args = process.argv.slice(2);
@@ -180,7 +181,7 @@ function makeWorker(index) {
180
181
  async function chromeWindow(i) {
181
182
  const profile = await mkdtemp(`${tmpdir()}/crowd-load-`);
182
183
  profiles.push(profile);
183
- const browser = await puppeteer.launch({
184
+ const browser = await launchChrome(puppeteer, {
184
185
  executablePath:
185
186
  opts.chrome ??
186
187
  process.env.CHROME_PATH ??
@@ -0,0 +1,49 @@
1
+ import {existsSync,mkdirSync,readFileSync,writeFileSync,renameSync} from 'node:fs';
2
+ import {dirname,isAbsolute,join} from 'node:path';
3
+ import {configPath,workerDir} from './studio.mjs';
4
+ /**
5
+ * A preprovisioned Workers for Platforms target, deployed by ordinary Wrangler
6
+ * (https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/get-started/configuration/).
7
+ * studio.json names it; the platform owns routing and resources and enforces the target at its API boundary:
8
+ *
9
+ * "cloudflare": {"dispatchNamespace":"…","worker":"…","d1Id":"…","domain":"…",
10
+ * "preview":{"worker":"…","d1Id":"…","domain":"…"}}
11
+ *
12
+ * `deploy --preview` sends the same build to the preview Worker and its own database.
13
+ */
14
+ const NAME=/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/,UUID=/^[0-9a-f-]{36}$/i,HOST=/^[a-z0-9.-]+$/i;
15
+ export function dispatchTarget(studio,{preview=false}={}) {
16
+ const cf=studio.cloudflare??{};
17
+ if(cf.dispatchNamespace===undefined) {
18
+ if(preview)throw Error('deploy --preview needs a Workers for Platforms preview target in studio.json (cloudflare.preview)');
19
+ return null;
20
+ }
21
+ if(typeof cf.dispatchNamespace!=='string'||!NAME.test(cf.dispatchNamespace))throw Error('Invalid cloudflare.dispatchNamespace');
22
+ const t=preview?cf.preview:cf;
23
+ if(!t||!NAME.test(t.worker??'')||!UUID.test(t.d1Id??'')||!HOST.test(t.domain??''))throw Error(`A dispatch target needs its preprovisioned worker, d1Id and domain in studio.json${preview?' (cloudflare.preview)':''}`);
24
+ return {namespace:cf.dispatchNamespace,worker:t.worker,d1Id:t.d1Id,domain:t.domain.toLowerCase(),preview};
25
+ }
26
+ /** The studio's generated wrangler.jsonc, pointed at the target: its Worker name and its database. */
27
+ export function targetConfig(root,target) {
28
+ const base=workerDir(root),json=JSON.parse(readFileSync(configPath(root),'utf8').replace(/^\s*\/\/.*$/gm,''));
29
+ const abs=p=>p&&!isAbsolute(p)?join(base,p):p;
30
+ delete json.$schema;
31
+ json.name=target.worker;
32
+ if(json.main)json.main=abs(json.main);
33
+ if(json.assets?.directory)json.assets.directory=abs(json.assets.directory);
34
+ if(!json.d1_databases?.length)throw Error('The studio has no D1 database in wrangler.jsonc');
35
+ json.d1_databases=json.d1_databases.map((d,i)=>({...d,...(i===0?{database_id:target.d1Id}:{}),...(d.migrations_dir?{migrations_dir:abs(d.migrations_dir)}:{})}));
36
+ // The platform routes its hostnames, runs its scheduled ticks and owns its queues: a dispatch Worker has no
37
+ // routes, workers.dev, cron triggers or queue bindings of its own.
38
+ for(const key of ['route','routes','workers_dev','preview_urls','triggers','queues'])delete json[key];
39
+ const file=join(base,'.wrangler',target.preview?'homie-preview.wrangler.json':'homie-target.wrangler.json');
40
+ mkdirSync(dirname(file),{recursive:true});
41
+ const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,`${JSON.stringify(json,null,2)}\n`);renameSync(temp,file);
42
+ return file;
43
+ }
44
+ export function dispatchArgs(args,target,config) {
45
+ if(!target||args.includes('--local'))return args;
46
+ if(args.some(a=>['--name','--dispatch-namespace','--config','-c'].includes(a)||/^--(name|dispatch-namespace|config)=/.test(a)))throw Error('The deployment target comes from studio.json');
47
+ if(!config||!existsSync(config))throw Error('The deployment target configuration is missing');
48
+ return ['deploy','secret'].includes(args[0])?[...args,'--config',config,'--dispatch-namespace',target.namespace]:[...args,'--config',config];
49
+ }
package/lib/lab-check.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { mkdirSync, writeFileSync } from 'node:fs';
25
25
  import { join, relative } from 'node:path';
26
- import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
26
+ import { chromeArgs, findChrome, noChrome, SOFTWARE_GL, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
27
27
  import { labDir, startLabServer } from './lab.mjs';
28
28
  import { isRulesGame, listExperiences as listGames } from './studio.mjs';
29
29
 
@@ -109,7 +109,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
109
109
  const game = id ?? (games.length === 1 ? games[0].id : null);
110
110
  if (!game || !games.some((g) => g.id === game)) return { ok: false, command: 'lab check', why: `name the game: homie-studio lab check <${games.map((g) => g.id).join('|') || 'id'}>` };
111
111
  const chrome = findChrome();
112
- if (!chrome) return { ok: false, command: 'lab check', why: noChrome() };
112
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'lab check', why: noChrome() };
113
113
  let puppeteer;
114
114
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'lab check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
115
115
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
@@ -117,7 +117,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
117
117
  mkdirSync(dir, { recursive: true });
118
118
  const server = await startLabServer(root, { port: 0, today, watchFiles: false, log: () => {} });
119
119
  let browser;
120
- try { browser = await puppeteer.launch({
120
+ try { browser = await launchChrome(puppeteer, {
121
121
  executablePath: chrome, headless: true, timeout: 150_000, protocolTimeout: 240_000,
122
122
  args: [...chromeArgs(), '--mute-audio', '--window-size=1600,1000', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb'],
123
123
  }); } catch (error) { await server.close(); throw error; }
package/lib/look.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
- import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
13
+ import { chromeArgs, findChrome, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
14
14
 
15
15
 
16
16
  export const LOOK_DEVICES = {
@@ -25,14 +25,14 @@ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi,
25
25
  export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
26
26
  if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
27
27
  const chrome = findChrome();
28
- if (!chrome) return { ok: false, command: 'look', why: noChrome() };
28
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'look', why: noChrome() };
29
29
  let puppeteer;
30
30
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
31
31
  const base = String(url).replace(/\/+$/, '');
32
32
  mkdirSync(shots, { recursive: true });
33
33
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
34
34
  // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
35
- const browser = await puppeteer.launch({
35
+ const browser = await launchChrome(puppeteer, {
36
36
  executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
37
37
  args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
38
38
  });
package/lib/mcp-tools.mjs CHANGED
@@ -810,6 +810,16 @@ export function toolDefs(ctx, avail = {}) {
810
810
  annotations: { title: 'Set the studio domain', ...RW },
811
811
  run: async (a) => { const r = studioDomain(ctx.root(a.studio), a.hostname); return r.ok ? ok(r.next, r) : fail(r.next, r); },
812
812
  },
813
+ {
814
+ name: 'studio_read_only', title: 'Make the studio read-only',
815
+ description: 'Freeze the live studio for a few minutes, e.g. while moving it to another host or during maintenance, and open it again: on, off, or status. Uses the studio\'s own Cloudflare sign-in to set or delete the Worker secret STUDIO_READ_ONLY; Cloudflare applies it at once, with no build or deploy, and live rooms reconnect once. While on, every change answers 503 with Retry-After and x-studio-read-only: 1; pages, games, live rooms and the Stripe webhook keep working. Then it reads the live site\'s /api/games readOnly to confirm. Turn it off when the work is done.',
816
+ inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['on', 'off', 'status'] }, url: str('Optional: the live site to check, when this computer does not know it'), ...STUDIO_ARG }, required: ['action'] },
817
+ annotations: { title: 'Make the studio read-only', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
818
+ run: async (a) => {
819
+ const result = await (await import('./read-only.mjs')).readOnlyCommand(ctx.root(a.studio), a.action, { url: a.url ?? null });
820
+ return result.ok ? ok(result.message, result) : fail(result.why, result);
821
+ },
822
+ },
813
823
  {
814
824
  name: 'setup_status', title: 'Setup status',
815
825
  description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
@@ -0,0 +1,18 @@
1
+ import {readStudio,siteUrl} from './studio.mjs';
2
+ import {runner} from './cloudflare.mjs';
3
+ export async function drainCommand(root,action,{run=null,fetchFn=fetch,url=null}={}){
4
+ if(!['on','off','status'].includes(action))throw Error('Use homie-studio drain on|off|status');
5
+ const studio=readStudio(root),cf=studio.cloudflare??{};
6
+ if(!cf.d1)throw Error('Deploy this studio before draining rooms');
7
+ if(action!=='status'){
8
+ const w=run??runner(root,cf.accountId?{CLOUDFLARE_ACCOUNT_ID:cf.accountId}:{});
9
+ const sql=action==='on'?`INSERT INTO meta(key,value) VALUES('studio_move_drain','${Date.now()}') ON CONFLICT(key) DO UPDATE SET value=CASE WHEN CAST(meta.value AS INTEGER)>0 THEN meta.value ELSE excluded.value END;`:"DELETE FROM meta WHERE key='studio_move_drain';";
10
+ const result=await w(['d1','execute',cf.d1,'--remote','--command',sql]);
11
+ if(result.code!==0)throw Error('Cloudflare could not change room admission; sign in and retry');
12
+ }
13
+ const site=url??siteUrl(root,studio);const response=await fetchFn(new URL('/api/move-status',site),{cache:'no-store',signal:AbortSignal.timeout(120000)});
14
+ if(!response.ok)throw Error('Room drain is unmeasured; keep the source running');
15
+ const status=await response.json();
16
+ if(typeof status.draining!=='boolean'||typeof status.drained!=='boolean')throw Error('Upgrade and deploy the toolkit before draining');
17
+ return {ok:true,command:'drain '+action,...status,message:status.drained?'All measured rooms are empty and their pending records are delivered. The source can now be made read-only.':status.draining?'Existing rooms keep playing. New admissions are paused. Run homie-studio drain status until drained is true before freezing or changing hosts.':'Room admissions are open.'};
18
+ }
package/lib/perf.mjs CHANGED
@@ -49,7 +49,7 @@ import { cpus, loadavg, tmpdir, totalmem } from 'node:os';
49
49
  import { basename, join, relative, resolve } from 'node:path';
50
50
  import { gzipSync } from 'node:zlib';
51
51
  import { bundleOf, gameDigest } from './build.mjs';
52
- import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
52
+ import { SOFTWARE_GL, chromeArgs, findChrome, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
53
53
  import { CUT_OFF, LAUNCH_TIMEOUT_MS, arrivalFacts, linkOf } from './check.mjs';
54
54
  import { reachSite, siteRefusal } from './net.mjs';
55
55
  import { judge, judgePaired, round, summarize } from './perf-stats.mjs';
@@ -211,7 +211,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
211
211
  const rate = device === 'phone' ? cpu : dev.cpu;
212
212
  const profileDir = mkdtempSync(join(tmpdir(), 'homie-studio-perf-'));
213
213
  const vp = dev.viewport;
214
- const browser = await puppeteer.launch({
214
+ const browser = await launchChrome(puppeteer, {
215
215
  executablePath: chrome, headless: true, userDataDir: profileDir, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
216
216
  args: [...chromeArgs(), '--mute-audio', '--autoplay-policy=no-user-gesture-required', `--window-size=${vp.width},${vp.height}`, '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb', '--enable-precise-memory-info'],
217
217
  });
@@ -548,7 +548,7 @@ export async function perfRun({ root = null, url, game, devices = ['computer', '
548
548
  const bad = devices.filter((d) => !PERF_DEVICES[d]);
549
549
  if (bad.length) return { ok: false, command: 'perf', why: `unknown device ${bad.join(', ')} (computer, phone)` };
550
550
  const chrome = findChrome();
551
- if (!chrome) return { ok: false, command: 'perf', why: noChrome() };
551
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'perf', why: noChrome() };
552
552
  let puppeteer;
553
553
  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)' }; }
554
554
  // One preflight from this process (lib/net.mjs `reachSite`): a name Node cannot look up, or no connection, is said as