@homie-rocks/studio 0.58.8 → 0.58.12

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,49 @@ 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.12] - 2026-10-20
21
+
22
+ **Plugin 0.58.6**
23
+
24
+ Every browser the toolkit opens can be a cloud browser, and a studio worked on away from the person's screen previews on its own private preview.
25
+
26
+ ### Added
27
+
28
+ - 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 (in 0.58.11 only check and the playtest script could): 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.
29
+ - A host that runs the toolkit away from the person's screen (Homie Managed's studio workspace, a cloud dev box) can name the address of the studio's private preview in `HOMIE_PREVIEW_URL`. For a studio whose studio.json has a preview target (`cloudflare.preview`), the local MCP server's `preview_run` then puts the build there (`deploy --preview`) and gives that address, `check` and `playtest` put the current build there and open it, and `preview_stop` has nothing to stop. Without it, the site runs on this computer as before.
30
+
31
+ ## [0.58.11] - 2026-10-19
32
+
33
+ **Plugin 0.58.5**
34
+
35
+ 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.
36
+
37
+ ### Added
38
+
39
+ - 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.
40
+ - `homie-studio check` and the playtest script 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.
41
+ - `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.
42
+ - Managed media bindings preserve streamed media, range requests and large player saves.
43
+ - 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`.
44
+
45
+ ### Fixed
46
+
47
+ - Standalone apps can read an app-record write's read-only refusal; other write routes remain restricted to the studio's own origin.
48
+
49
+ ## [0.58.9] - 2026-10-19
50
+
51
+ **Plugin 0.58.3**
52
+
53
+ Build games from shared parts while keeping server rules deterministic.
54
+
55
+ ### Fixed
56
+
57
+ - 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.
58
+
59
+ ### Changed
60
+
61
+ - Game guides require finding, adding and actually using fitting parts before writing replacements, with source credits and concrete gaps recorded in Built from.
62
+
20
63
  ## [0.58.8] - 2026-10-19
21
64
 
22
65
  **Plugin 0.58.3**
@@ -165,6 +208,9 @@ A room nobody is playing rests, and the next touch wakes it with that input stil
165
208
  - Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
166
209
  going while people are present. A lasting world never rests.
167
210
 
211
+ ### Added
212
+
213
+
168
214
  ## [0.58.0] - 2026-10-16
169
215
 
170
216
  **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
  });