@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 +35 -0
- package/README.md +34 -0
- package/bin/homie-studio.mjs +19 -2
- package/guides/art/scripts/art.mjs +4 -3
- package/guides/game/RULES.md +2 -0
- package/guides/game/SKILL.md +2 -0
- package/guides/playtest/scripts/playtest.mjs +4 -4
- package/guides/video/scripts/lib/browser.mjs +23 -2
- package/lib/check.mjs +4 -4
- package/lib/chrome.mjs +23 -0
- package/lib/cloudflare.mjs +18 -8
- package/lib/crowd-load.mjs +2 -1
- package/lib/dispatch-target.mjs +49 -0
- package/lib/lab-check.mjs +3 -3
- package/lib/look.mjs +3 -3
- package/lib/mcp-tools.mjs +10 -0
- package/lib/move-drain.mjs +18 -0
- package/lib/perf.mjs +3 -3
- package/lib/port-check.mjs +3 -3
- package/lib/read-only.mjs +69 -0
- package/lib/render3d.mjs +3 -3
- package/lib/rules-guard.mjs +30 -12
- package/lib/shoot.mjs +3 -3
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +33 -8
- package/worker/lobby.mjs +2 -1
- package/worker/managed-media.mjs +16 -0
- package/worker/move-drain.mjs +47 -0
- package/worker/read-only.mjs +113 -0
- package/worker/routes.mjs +12 -1
- package/worker/stream-objects.mjs +3 -2
- package/worker/table.mjs +10 -1
- package/worker/version.mjs +1 -1
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
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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();
|
package/guides/game/RULES.md
CHANGED
|
@@ -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';
|
package/guides/game/SKILL.md
CHANGED
|
@@ -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
|
|
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 <
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 = []; }
|
package/lib/crowd-load.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|