@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/lib/mcp-tools.mjs CHANGED
@@ -32,6 +32,7 @@ import { newStudio, slugify } from './scaffold.mjs';
32
32
  import { repoFromUrl, studioRepo } from './repo.mjs';
33
33
  import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listExperiences as listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
34
34
  import { runningDev } from './dev.mjs';
35
+ import { dispatchTarget } from './dispatch-target.mjs';
35
36
  import { deployWords } from './deploy-state.mjs';
36
37
  import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
37
38
  import { STUDIO_VERSION } from './version.mjs';
@@ -443,7 +444,7 @@ async function startRun(ctx, root, { kind, game, title, steps }) {
443
444
  const run = { kind, title, state: 'running', ...(await feedForRun(ctx, root, { id: game ?? null, title })), jobs: [] };
444
445
  ctx.runs.set(run.build, run);
445
446
  const next = (i) => {
446
- if (i >= steps.length) { run.state = 'passed'; endRun(root, run, 'passed', `${title}: done.`); if (kind !== 'deploy') ctx.note(root).checked = true; return; }
447
+ if (i >= steps.length) { run.state = 'passed'; endRun(root, run, 'passed', `${title}: done.`); if (kind === 'check' || kind === 'playtest') ctx.note(root).checked = true; return; }
447
448
  const step = steps[i];
448
449
  const job = cliJob(root, step.label, step.args, {
449
450
  onEnd: (j) => {
@@ -459,7 +460,30 @@ async function startRun(ctx, root, { kind, game, title, steps }) {
459
460
  return buildCard(ctx, root, feedFor(root, run.build));
460
461
  }
461
462
 
463
+ /**
464
+ * WHERE "HERE" IS, FOR A HOST AWAY FROM THE PERSON'S SCREEN. A platform that runs this toolkit for the person in a
465
+ * workspace of its own (Homie Managed's studio workspace, a cloud dev box) has no browser beside it that can open
466
+ * this computer's addresses. It names the address its studio's private preview is reachable at, HOMIE_PREVIEW_URL,
467
+ * and the studio's studio.json names that preview's Workers for Platforms target (cloudflare.preview). Then the
468
+ * studio's site "here" is that preview: preview_run puts the build there (deploy --preview), and check and playtest
469
+ * open it after doing the same. Without both, nothing changes: the site runs on this computer.
470
+ */
471
+ export function hostedPreview(root, env = process.env) {
472
+ const raw = String(env.HOMIE_PREVIEW_URL ?? '').trim();
473
+ if (!raw) return null;
474
+ let url;
475
+ try { url = new URL(raw); } catch { return null; }
476
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null;
477
+ try { if (!dispatchTarget(readStudio(root), { preview: true })) return null; } catch { return null; }
478
+ return url.href.replace(/\/+$/, '');
479
+ }
480
+
481
+ /** The step that puts the studio's current build on its hosted preview (a deploy --preview builds first). */
482
+ const PREVIEW_STEP = { label: 'the build on the private preview', args: ['deploy', '--preview'] };
483
+
462
484
  async function devUrl(ctx, root, port) {
485
+ const hosted = hostedPreview(root);
486
+ if (hosted) return hosted;
463
487
  const dev = devOf(root);
464
488
  if (dev) return dev.url;
465
489
  const r = await startDev(ctx, root, port);
@@ -810,6 +834,16 @@ export function toolDefs(ctx, avail = {}) {
810
834
  annotations: { title: 'Set the studio domain', ...RW },
811
835
  run: async (a) => { const r = studioDomain(ctx.root(a.studio), a.hostname); return r.ok ? ok(r.next, r) : fail(r.next, r); },
812
836
  },
837
+ {
838
+ name: 'studio_read_only', title: 'Make the studio read-only',
839
+ 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.',
840
+ 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'] },
841
+ annotations: { title: 'Make the studio read-only', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
842
+ run: async (a) => {
843
+ const result = await (await import('./read-only.mjs')).readOnlyCommand(ctx.root(a.studio), a.action, { url: a.url ?? null });
844
+ return result.ok ? ok(result.message, result) : fail(result.why, result);
845
+ },
846
+ },
813
847
  {
814
848
  name: 'setup_status', title: 'Setup status',
815
849
  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.',
@@ -1052,6 +1086,18 @@ export function toolDefs(ctx, avail = {}) {
1052
1086
  run: async (a) => {
1053
1087
  const root = ctx.root(a.studio);
1054
1088
  const need = needsInstall(ctx, root); if (need) return need;
1089
+ const hosted = hostedPreview(root);
1090
+ if (hosted) {
1091
+ const games = listGames(root);
1092
+ const g = a.game ? games.find((x) => x.id === a.game) : games[0];
1093
+ const card = await startRun(ctx, root, { kind: 'preview', game: g?.id ?? null, title: `${readStudio(root).name ?? 'The studio'}: private preview`, steps: [PREVIEW_STEP] });
1094
+ const lines = [
1095
+ `The studio's site runs on its private preview, not on this computer: the build is going there now (the card follows it). When it is done: ${hosted}/${games.length ? '' : ' (the home page: "First game coming soon")'}`,
1096
+ ...games.map((x) => ` ${x.name ?? x.id}: ${hosted}/${x.id}/play`),
1097
+ g ? `The person opens ${hosted}/${g.id}/play on two screens (a computer and a phone): two players, one room. Give them the address; do not open a browser for them.` : 'Give the person the address; do not open a browser for them.',
1098
+ ];
1099
+ return { ...card, content: [{ type: 'text', text: lines.join('\n') }, ...(card.content ?? [])] };
1100
+ }
1055
1101
  let url = devOf(root)?.url ?? null;
1056
1102
  let slow = false;
1057
1103
  if (!url) { const r = await startDev(ctx, root, a.port); if (r.error) return fail(r.error); url = r.url; slow = Boolean(r.slow); }
@@ -1071,6 +1117,8 @@ export function toolDefs(ctx, avail = {}) {
1071
1117
  annotations: { title: 'Stop the site here', ...RW },
1072
1118
  run: async (a) => {
1073
1119
  const root = ctx.root(a.studio);
1120
+ const hosted = hostedPreview(root);
1121
+ if (hosted) return ok(`Nothing runs on this computer to stop: the studio's private preview (${hosted}/) stays as it is until the next build is put there.`, { kind: 'preview', stopped: [], hosted });
1074
1122
  const r = await cli(ctx, root, 'dev --stop', ['dev', '--stop'], { wait: 20_000 });
1075
1123
  // What a server stopped another way left behind is cleaned up and said (lib/dev.mjs).
1076
1124
  const note = r.result?.why && (r.result?.stale || r.result?.stopped?.length) ? ` ${r.result.why[0].toUpperCase()}${r.result.why.slice(1)}.` : '';
@@ -1089,8 +1137,10 @@ export function toolDefs(ctx, avail = {}) {
1089
1137
  const game = a.game ?? (games.length === 1 ? games[0].id : null);
1090
1138
  if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
1091
1139
  const url = a.url ? String(a.url) : await devUrl(ctx, root);
1140
+ const hosted = !a.url && hostedPreview(root);
1092
1141
  return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: ${games.find((g) => g.id === game)?.kind === 'app' ? 'multi-screen' : 'two-browser'} check`, steps: [
1093
1142
  ...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
1143
+ ...(hosted ? [PREVIEW_STEP] : []),
1094
1144
  { label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
1095
1145
  ] });
1096
1146
  },
@@ -1107,9 +1157,11 @@ export function toolDefs(ctx, avail = {}) {
1107
1157
  const game = a.game ?? (games.length === 1 ? games[0].id : null);
1108
1158
  if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to playtest yet');
1109
1159
  const url = a.url ? String(a.url) : await devUrl(ctx, root);
1160
+ const hosted = !a.url && hostedPreview(root);
1110
1161
  const shots = join('.checks', `${game}-look`);
1111
1162
  return startRun(ctx, root, { kind: 'playtest', game, title: `${games.find((g) => g.id === game)?.name ?? game}: playtest`, steps: [
1112
1163
  ...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
1164
+ ...(hosted ? [PREVIEW_STEP] : []),
1113
1165
  { label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
1114
1166
  { label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}/${games.find((g) => g.id === game)?.kind === 'app' ? 'open' : 'play'}`, '--url', url, '--only', 'computer,phone', '--shots', shots],
1115
1167
  // The first screen of each page first (a whole page is a tall picture): what a person sees on arrival.
@@ -0,0 +1,18 @@
1
+ import {readStudio,siteUrl} from './studio.mjs';
2
+ import {runner} from './cloudflare.mjs';
3
+ export async function drainCommand(root,action,{run=null,fetchFn=fetch,url=null}={}){
4
+ if(!['on','off','status'].includes(action))throw Error('Use homie-studio drain on|off|status');
5
+ const studio=readStudio(root),cf=studio.cloudflare??{};
6
+ if(!cf.d1)throw Error('Deploy this studio before draining rooms');
7
+ if(action!=='status'){
8
+ const w=run??runner(root,cf.accountId?{CLOUDFLARE_ACCOUNT_ID:cf.accountId}:{});
9
+ const sql=action==='on'?`INSERT INTO meta(key,value) VALUES('studio_move_drain','${Date.now()}') ON CONFLICT(key) DO UPDATE SET value=CASE WHEN CAST(meta.value AS INTEGER)>0 THEN meta.value ELSE excluded.value END;`:"DELETE FROM meta WHERE key='studio_move_drain';";
10
+ const result=await w(['d1','execute',cf.d1,'--remote','--command',sql]);
11
+ if(result.code!==0)throw Error('Cloudflare could not change room admission; sign in and retry');
12
+ }
13
+ const site=url??siteUrl(root,studio);const response=await fetchFn(new URL('/api/move-status',site),{cache:'no-store',signal:AbortSignal.timeout(120000)});
14
+ if(!response.ok)throw Error('Room drain is unmeasured; keep the source running');
15
+ const status=await response.json();
16
+ if(typeof status.draining!=='boolean'||typeof status.drained!=='boolean')throw Error('Upgrade and deploy the toolkit before draining');
17
+ return {ok:true,command:'drain '+action,...status,message:status.drained?'All measured rooms are empty and their pending records are delivered. The source can now be made read-only.':status.draining?'Existing rooms keep playing. New admissions are paused. Run homie-studio drain status until drained is true before freezing or changing hosts.':'Room admissions are open.'};
18
+ }
package/lib/perf.mjs CHANGED
@@ -49,7 +49,7 @@ import { cpus, loadavg, tmpdir, totalmem } from 'node:os';
49
49
  import { basename, join, relative, resolve } from 'node:path';
50
50
  import { gzipSync } from 'node:zlib';
51
51
  import { bundleOf, gameDigest } from './build.mjs';
52
- import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
52
+ import { SOFTWARE_GL, chromeArgs, findChrome, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
53
53
  import { CUT_OFF, LAUNCH_TIMEOUT_MS, arrivalFacts, linkOf } from './check.mjs';
54
54
  import { reachSite, siteRefusal } from './net.mjs';
55
55
  import { judge, judgePaired, round, summarize } from './perf-stats.mjs';
@@ -211,7 +211,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
211
211
  const rate = device === 'phone' ? cpu : dev.cpu;
212
212
  const profileDir = mkdtempSync(join(tmpdir(), 'homie-studio-perf-'));
213
213
  const vp = dev.viewport;
214
- const browser = await puppeteer.launch({
214
+ const browser = await launchChrome(puppeteer, {
215
215
  executablePath: chrome, headless: true, userDataDir: profileDir, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
216
216
  args: [...chromeArgs(), '--mute-audio', '--autoplay-policy=no-user-gesture-required', `--window-size=${vp.width},${vp.height}`, '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb', '--enable-precise-memory-info'],
217
217
  });
@@ -548,7 +548,7 @@ export async function perfRun({ root = null, url, game, devices = ['computer', '
548
548
  const bad = devices.filter((d) => !PERF_DEVICES[d]);
549
549
  if (bad.length) return { ok: false, command: 'perf', why: `unknown device ${bad.join(', ')} (computer, phone)` };
550
550
  const chrome = findChrome();
551
- if (!chrome) return { ok: false, command: 'perf', why: noChrome() };
551
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'perf', why: noChrome() };
552
552
  let puppeteer;
553
553
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'perf', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
554
554
  // One preflight from this process (lib/net.mjs `reachSite`): a name Node cannot look up, or no connection, is said as
@@ -33,7 +33,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import { createRequire } from 'node:module';
34
34
  import { tmpdir } from 'node:os';
35
35
  import { join } from 'node:path';
36
- import { chromeArgs, findChrome, isLoopbackUrl, noChrome } from './chrome.mjs';
36
+ import { chromeArgs, findChrome, isLoopbackUrl, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
37
37
  import { reachSite, siteRefusal } from './net.mjs';
38
38
 
39
39
  const GPU = [
@@ -245,7 +245,7 @@ async function chrome(puppeteer, kind, exe) {
245
245
  const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
246
246
  const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
247
247
  // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
248
- const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, timeout: 150_000, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
248
+ const browser = await launchChrome(puppeteer, { executablePath: exe, headless: true, userDataDir: dir, timeout: 150_000, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
249
249
  const page = (await browser.pages())[0] ?? await browser.newPage();
250
250
  if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
251
251
  else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
@@ -501,7 +501,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
501
501
  const base = String(url).replace(/\/+$/, '');
502
502
  const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
503
503
  const exe = findChrome();
504
- if (!exe) return { ok: false, command: 'port check', why: noChrome() };
504
+ if (!exe && !remoteBrowserEndpoint()) return { ok: false, command: 'port check', why: noChrome() };
505
505
  const req = createRequire(join(root ?? process.cwd(), 'package.json'));
506
506
  let puppeteer;
507
507
  try { puppeteer = (await import('puppeteer-core')).default; } catch { try { puppeteer = req('puppeteer-core'); } catch { return { ok: false, command: 'port check', why: 'puppeteer-core is missing (it comes with @homie-rocks/studio: npm install)' }; } }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * homie-studio read-only on|off|status [--url <site>]
3
+ *
4
+ * Freezes the studio for a few minutes (a move to another host, maintenance) without losing anything: the Worker
5
+ * secret STUDIO_READ_ONLY=1, set and deleted with the studio's own Wrangler sign-in. Cloudflare applies a secret to
6
+ * the live Worker at once, with no build or deploy, and a deploy made while it is on keeps it on. What the Worker
7
+ * does while it is on is worker/read-only.mjs. After the change the command reads the live site's /api/games
8
+ * (`readOnly`) until it agrees, so the person is told what the site does, not only what was asked of Cloudflare.
9
+ */
10
+ import {readStudio,siteUrl} from './studio.mjs';
11
+ import {runner} from './cloudflare.mjs';
12
+ import {redactText} from './redact.mjs';
13
+ import {READ_ONLY_HEADER,READ_ONLY_RETRY_SECONDS} from '../worker/read-only.mjs';
14
+
15
+ export const READ_ONLY_SETTING='STUDIO_READ_ONLY';
16
+ const ACTIONS=['on','off','status'];
17
+
18
+ const wranglerSaid=r=>redactText(String([r?.out,r?.stdout].filter(Boolean).join('\n')||'Wrangler failed')).text.trim().slice(-1200);
19
+
20
+ /** What the live site says now: true or false from /api/games, null when it could not be read. */
21
+ async function liveReadOnly(site,want,{fetchFn,wait,tries}) {
22
+ let seen=null;
23
+ for(let i=0;i<tries;i++) {
24
+ if(i&&wait)await new Promise(r=>setTimeout(r,wait));
25
+ try {
26
+ const res=await fetchFn(new URL('/api/games',site),{headers:{accept:'application/json'},signal:AbortSignal.timeout(10_000)});
27
+ const body=res.ok?await res.json():null;
28
+ seen=typeof body?.readOnly==='boolean'?body.readOnly:null;
29
+ } catch {seen=null;}
30
+ if(seen===want)return seen;
31
+ }
32
+ return seen;
33
+ }
34
+
35
+ export async function readOnlyCommand(root,action,{run=null,fetchFn=fetch,url=null,wait=2000,tries=10}={}) {
36
+ if(!ACTIONS.includes(action))throw new Error('Use homie-studio read-only on, off or status');
37
+ const command=`read-only ${action}`;
38
+ const studio=readStudio(root),cf=studio.cloudflare??{};
39
+ if(!cf.worker)return {ok:false,command,needs:'deploy',why:'This studio is not on Cloudflare yet, so there is nothing to make read-only: deploy it first (homie-studio deploy).'};
40
+ const w=run??runner(root,cf.accountId?{CLOUDFLARE_ACCOUNT_ID:cf.accountId}:{});
41
+ const listed=await w(['secret','list','--name',cf.worker]);
42
+ let names=null;
43
+ try{names=listed.code===0?JSON.parse(listed.stdout??listed.out).map(s=>s.name):null;}catch{names=null;}
44
+ if(!names)return {ok:false,command,needs:'cloudflare',why:`Wrangler could not read the Worker's settings (sign in with npx wrangler login, then run this again).\n${wranglerSaid(listed)}`};
45
+ const was=names.includes(READ_ONLY_SETTING);
46
+ const readOnly=action==='status'?was:action==='on';
47
+ const already=action!=='status'&&was===readOnly;
48
+ if(!already&&action!=='status') {
49
+ // The value travels on stdin, as every Worker secret does here; Cloudflare applies it as a new Worker version.
50
+ const changed=action==='on'
51
+ ?await w(['secret','put',READ_ONLY_SETTING,'--name',cf.worker],{input:'1\n'})
52
+ :await w(['secret','delete',READ_ONLY_SETTING,'--name',cf.worker],{input:'y\n'});
53
+ if(changed.code!==0)return {ok:false,command,needs:'cloudflare',why:`Cloudflare did not take the change, so the studio is still ${was?'read-only':'open'}.\n${wranglerSaid(changed)}`};
54
+ }
55
+ const site=url??siteUrl(root,studio);
56
+ const live=site?await liveReadOnly(site,readOnly,{fetchFn,wait,tries:action==='status'?1:tries}):null;
57
+ const state=readOnly?'read-only':'open';
58
+ const lines=[
59
+ action==='status'?`The studio is ${state} (Cloudflare's setting ${READ_ONLY_SETTING} is ${was?'set':'not set'}).`
60
+ :already?`The studio was already ${state}: nothing was changed.`
61
+ :readOnly?`The studio is read-only now. Every change answers 503 with Retry-After: ${READ_ONLY_RETRY_SECONDS} and ${READ_ONLY_HEADER}: 1 until you run homie-studio read-only off. Pages, games and live rooms keep working, and Stripe payments are still fulfilled.`
62
+ :'The studio is open again: changes are taken, and what waited (room records, queued work) is delivered.',
63
+ live===null?(site?`${site}/api/games did not answer, so what the live site does is unmeasured: run homie-studio read-only status.`:'This computer does not know the site\'s address: pass --url to check it.')
64
+ :live===readOnly?`${site}/api/games says readOnly: ${live}.`
65
+ :`${site}/api/games still says readOnly: ${live}. Cloudflare takes a few seconds: run homie-studio read-only status.`,
66
+ ];
67
+ if(!already&&action!=='status')lines.push('Cloudflare applied it as a new version of the Worker, as a deploy does: anyone in a live room reconnects once.');
68
+ return {ok:true,command,readOnly,already,setting:READ_ONLY_SETTING,header:READ_ONLY_HEADER,worker:cf.worker,site,live,message:lines.join('\n')};
69
+ }
package/lib/render3d.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
- import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
18
+ import { chromeArgs, findChrome, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
19
19
  import { PACKAGE_ROOT } from './studio.mjs';
20
20
 
21
21
  let bundled = null;
@@ -50,12 +50,12 @@ export function saveDataUrl(url, file) { const { bytes } = dataUrlBytes(url); wr
50
50
  */
51
51
  export async function withRenderer(fn, { log = () => {} } = {}) {
52
52
  const chrome = findChrome();
53
- if (!chrome) throw new Error(noChrome());
53
+ if (!chrome && !remoteBrowserEndpoint()) throw new Error(noChrome());
54
54
  let puppeteer;
55
55
  try { puppeteer = (await import('puppeteer-core')).default; } catch { throw new Error('puppeteer-core is not installed (it comes with @homie-rocks/studio: npm install)'); }
56
56
  const script = await renderScript();
57
57
  const profile = mkdtempSync(join(tmpdir(), 'homie-render-'));
58
- const browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 300_000, args: [...chromeArgs(), '--no-first-run', '--no-default-browser-check', '--hide-scrollbars'] });
58
+ const browser = await launchChrome(puppeteer, { executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 300_000, args: [...chromeArgs(), '--no-first-run', '--no-default-browser-check', '--hide-scrollbars'] });
59
59
  try {
60
60
  const page = await browser.newPage();
61
61
  const errors = [];
@@ -38,12 +38,14 @@
38
38
  * code written to attack: no rules from outside the studio run on its server.
39
39
  */
40
40
  import { parse } from '@babel/parser';
41
+ import { listOwnParts, readOrigins, vendorDir } from './parts.mjs';
42
+ import { partsPlugin, resolvePartImport } from './parts-build.mjs';
41
43
  import babelTraverse from '@babel/traverse';
42
44
  import * as babelGenerator from '@babel/generator';
43
45
  import * as t from '@babel/types';
44
46
  import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
45
47
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
46
- import { dirname, join, relative, resolve } from 'node:path';
48
+ import { basename, dirname, join, relative, resolve } from 'node:path';
47
49
 
48
50
  const traverse = babelTraverse.default ?? babelTraverse;
49
51
  const generate = babelGenerator.generate ?? babelGenerator.default;
@@ -190,7 +192,7 @@ const isTarget = (path) => (path.parentPath.isAssignmentExpression() && path.par
190
192
  * and nothing is rewritten
191
193
  * Returns { code, problems }.
192
194
  */
193
- export function guardSource(code, { file = 'rules.js', map = null, linked = false } = {}) {
195
+ export function guardSource(code, { file = 'rules.js', map = null, linked = false, checkedImports = false } = {}) {
194
196
  const problems = [];
195
197
  const tracer = map ? new TraceMap(typeof map === 'string' ? JSON.parse(map) : map) : null;
196
198
  const lineOf = (node) => {
@@ -240,12 +242,12 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
240
242
  if (t.isImportDeclaration(st)) {
241
243
  const from = st.source.value;
242
244
  if (from === RULES_MODULE || (linked && from === GUARD_MODULE)) { for (const s of st.specifiers) homie.add(s.local.name); continue; }
243
- if (/^\.\.?\//.test(from) && !linked) { for (const s of st.specifiers) own.add(s.local.name); continue; }
245
+ if ((/^\.\.?\//.test(from) || from.startsWith('@parts/')) && !linked) { for (const s of st.specifiers) own.add(s.local.name); continue; }
244
246
  bad(st, `rules import only ${RULES_MODULE} and the game's own files, not "${from}"`);
245
247
  continue;
246
248
  }
247
249
  const decl = t.isExportNamedDeclaration(st) || t.isExportDefaultDeclaration(st) ? st.declaration : st;
248
- if (t.isExportAllDeclaration(st) || (t.isExportNamedDeclaration(st) && !st.declaration)) { if (st.source) bad(st, 'rules export their own declarations, not another file\'s'); continue; }
250
+ if (t.isExportAllDeclaration(st) || (t.isExportNamedDeclaration(st) && !st.declaration)) { if (st.source && (!checkedImports || linked || !/^\.\.?\//.test(st.source.value) && !st.source.value.startsWith('@parts/'))) bad(st, 'rules export their own declarations, or re-export checked local files or parts'); continue; }
249
251
  if (t.isFunctionDeclaration(decl)) continue;
250
252
  if (t.isVariableDeclaration(decl)) {
251
253
  // Removing types turns `export default <expression>` into a `var` that is exported as the default: that one is the module's own.
@@ -719,11 +721,22 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
719
721
  const problems = [];
720
722
  const todo = [entry];
721
723
  const sites = {};
724
+ const canonical = p => existsSync(p) ? realpathSync(p) : resolve(p);
725
+ const inside = (dir, file) => { const rel = relative(canonical(dir), canonical(file)); return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')); };
726
+ const origins = readOrigins(root);
727
+ const game = basename(gameDir);
728
+ const vendors = Object.entries(origins.parts).filter(([, entry]) => {
729
+ const purchase = entry.purchases?.[game] ?? entry.purchase;
730
+ return purchase?.scope !== 'game' || purchase.game === game;
731
+ }).map(([ref]) => vendorDir(root, ref));
732
+ const partDirs = [...listOwnParts(root).filter(p => p.part).map(p => p.dir), ...vendors]
733
+ .filter(p => inside(root, p) && existsSync(join(p, 'part.json')));
734
+ const allowed = file => inside(gameDir, file) || partDirs.some(dir => inside(dir, file));
722
735
  while (todo.length) {
723
736
  const file = todo.pop();
724
737
  if (files.has(file)) continue;
725
738
  const shown = relative(root, file).split('\\').join('/');
726
- if (relative(gameDir, file).startsWith('..')) { problems.push({ file: shown, line: 0, message: 'rules import only files of their own game' }); files.set(file, null); continue; }
739
+ if (!allowed(file)) { problems.push({ file: shown, line: 0, message: 'rules import only files of their own game' }); files.set(file, null); continue; }
727
740
  let stripped;
728
741
  try { stripped = await esbuild.transform(readFileSync(file, 'utf8'), { loader: /\.ts$/.test(file) ? 'ts' : 'js', format: 'esm', target: 'es2022', sourcemap: 'external', sourcefile: shown }); } catch (error) {
729
742
  const first = error.errors?.[0];
@@ -744,15 +757,20 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
744
757
  }
745
758
  },
746
759
  });
747
- const res = guardSource(stripped.code, { file: shown, map: stripped.map });
760
+ const res = guardSource(stripped.code, { file: shown, map: stripped.map, checkedImports: true });
748
761
  problems.push(...res.problems);
749
762
  files.set(file, res.code);
750
- for (const m of stripped.code.matchAll(/^\s*import\s[^'"]*['"](\.\.?\/[^'"]+)['"]/gm)) {
751
- const next = resolveOwn(file, m[1]);
752
- const line = original.program.body.find(n => n.type === 'ImportDeclaration' && n.source.value === m[1])?.loc?.start.line ?? 1;
753
- if (next && relative(gameDir,next).startsWith('..')) problems.push({file:shown,line,message:'rules import only files of their own game'});
763
+ const module = parse(stripped.code, { sourceType: 'module' });
764
+ for (const declaration of module.program.body) {
765
+ if (!['ImportDeclaration', 'ExportAllDeclaration', 'ExportNamedDeclaration'].includes(declaration.type) || !declaration.source) continue;
766
+ const spec = declaration.source.value;
767
+ if (!/^\.\.?\//.test(spec) && !spec.startsWith('@parts/')) continue;
768
+ const resolved = spec.startsWith('@parts/') ? resolvePartImport(root, spec, origins) : { path: resolveOwn(file, spec) };
769
+ const next = resolved.path;
770
+ const line = original.program.body.find(n => n.source?.value === spec)?.loc?.start.line ?? 1;
771
+ if (next && !allowed(next)) problems.push({file:shown,line,message:'rules import only files of their own game or registered parts'});
754
772
  else if (next) todo.push(next);
755
- else problems.push({ file: shown, line, message: `"${m[1]}" is not a file of this game` });
773
+ else problems.push({file:shown,line,message:resolved.error ?? `"${spec}" is not a file of this game or a registered part`});
756
774
  }
757
775
  }
758
776
  return { files, problems, sites };
@@ -787,7 +805,7 @@ export async function guardRules(esbuild, root, gameDir, { entry = join(gameDir,
787
805
  if (problems.length) return { ok: false, code: null, problems, files };
788
806
  let linked;
789
807
  try {
790
- linked = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', write: false, absWorkingDir: root, logLevel: 'silent', external: [RULES_MODULE, GUARD_MODULE], plugins: [guardedPlugin(files)], legalComments: 'none' });
808
+ linked = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', write: false, absWorkingDir: root, logLevel: 'silent', external: [RULES_MODULE, GUARD_MODULE], plugins: [partsPlugin(root, basename(gameDir)), guardedPlugin(files)], legalComments: 'none' });
791
809
  } catch (error) {
792
810
  const first = error.errors?.[0];
793
811
  return { ok: false, code: null, files, problems: [{ file: first?.location?.file ?? relative(root, entry), line: first?.location?.line ?? 0, message: `the rules did not link: ${first?.text ?? error.message}` }] };
package/lib/shoot.mjs CHANGED
@@ -41,7 +41,7 @@ import { tmpdir } from 'node:os';
41
41
  import { join } from 'node:path';
42
42
  import { bundleOf, gameDigest } from './build.mjs';
43
43
  import { arrivalReadiness, LAUNCH_TIMEOUT_MS } from './check.mjs';
44
- import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
44
+ import { SOFTWARE_GL, chromeArgs, findChrome, noChrome, launchChrome, remoteBrowserEndpoint } from './chrome.mjs';
45
45
  import { reachSite, siteRefusal } from './net.mjs';
46
46
  import { previewServer } from './preview.mjs';
47
47
 
@@ -153,7 +153,7 @@ async function open(puppeteer, chrome, device, url, profiles, browsers, { previe
153
153
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-shoot-'));
154
154
  profiles.push(profile);
155
155
  const vp = SHOOT_DEVICES[device];
156
- const browser = await puppeteer.launch({
156
+ const browser = await launchChrome(puppeteer, {
157
157
  executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
158
158
  args: [...chromeArgs(), '--mute-audio', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check', `--window-size=${vp.width},${vp.height}`, '--hide-scrollbars', '--force-color-profile=srgb'],
159
159
  });
@@ -191,7 +191,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
191
191
  if (!(Number.isInteger(frames) && frames >= 1 && frames <= 1800)) return { ok: false, command: 'shoot', why: '--frames 1..1800' };
192
192
  if (!(fps >= 1 && fps <= 120)) return { ok: false, command: 'shoot', why: '--fps 1..120' };
193
193
  const chrome = findChrome();
194
- if (!chrome) return { ok: false, command: 'shoot', why: noChrome() };
194
+ if (!chrome && !remoteBrowserEndpoint()) return { ok: false, command: 'shoot', why: noChrome() };
195
195
  let puppeteer;
196
196
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'shoot', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
197
197
  // --preview: this command serves the one built game itself (no Wrangler, no rooms) and shoots that page.
@@ -4512,6 +4512,90 @@
4512
4512
  }
4513
4513
  }
4514
4514
  }
4515
+ },
4516
+ "0.58.11": {
4517
+ "files": {
4518
+ "CLAUDE.md": "ddf2b2a0376202f6",
4519
+ "README.md": "684b3cca8001673c",
4520
+ "HANDOFF.md": "29337e2ace95b2e7",
4521
+ "games/README.md": "5ad32fe80c61f117",
4522
+ "music/README.md": "a3dc042dadd39df8",
4523
+ "videos/README.md": "16babc3437d5545b",
4524
+ "posts/README.md": "37841aae476bd540",
4525
+ "site/README.md": "1dbdf22c03a1e505",
4526
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4527
+ },
4528
+ "sections": {
4529
+ "AGENTS.md": {
4530
+ "(intro)": "af7f2f732505d379",
4531
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4532
+ "## Layout": "f3a280b8c96cd068",
4533
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4534
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4535
+ "## The site": "0cb8dd9a6f8a0096",
4536
+ "## Making games": "bb715272fd1a615b",
4537
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4538
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4539
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4540
+ "## Servers and AI seats": "1ccfd549d2369a60",
4541
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4542
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4543
+ "## Rules": "b2234d80f6cbe71d",
4544
+ "## Telling Homie": "80b46fdd9e578db8",
4545
+ "## Beta": "9d02638207a26e73"
4546
+ }
4547
+ },
4548
+ "named": {
4549
+ "Homie Arcade": {
4550
+ "sections": {
4551
+ "AGENTS.md": {
4552
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4553
+ }
4554
+ }
4555
+ }
4556
+ }
4557
+ },
4558
+ "0.58.12": {
4559
+ "files": {
4560
+ "CLAUDE.md": "ddf2b2a0376202f6",
4561
+ "README.md": "684b3cca8001673c",
4562
+ "HANDOFF.md": "29337e2ace95b2e7",
4563
+ "games/README.md": "5ad32fe80c61f117",
4564
+ "music/README.md": "a3dc042dadd39df8",
4565
+ "videos/README.md": "16babc3437d5545b",
4566
+ "posts/README.md": "37841aae476bd540",
4567
+ "site/README.md": "1dbdf22c03a1e505",
4568
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4569
+ },
4570
+ "sections": {
4571
+ "AGENTS.md": {
4572
+ "(intro)": "af7f2f732505d379",
4573
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4574
+ "## Layout": "f3a280b8c96cd068",
4575
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4576
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4577
+ "## The site": "0cb8dd9a6f8a0096",
4578
+ "## Making games": "bb715272fd1a615b",
4579
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4580
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4581
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4582
+ "## Servers and AI seats": "1ccfd549d2369a60",
4583
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4584
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4585
+ "## Rules": "b2234d80f6cbe71d",
4586
+ "## Telling Homie": "80b46fdd9e578db8",
4587
+ "## Beta": "9d02638207a26e73"
4588
+ }
4589
+ },
4590
+ "named": {
4591
+ "Homie Arcade": {
4592
+ "sections": {
4593
+ "AGENTS.md": {
4594
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4595
+ }
4596
+ }
4597
+ }
4598
+ }
4515
4599
  }
4516
4600
  }
4517
4601
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.58.8",
3
+ "version": "0.58.12",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.58.8","sha256":"dd01f171a16a619c527dc71c2beaaf0c9a9b70c395cb28156214d3ac988f81c5"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.12","sha256":"c4b8a5e3b41289d47fc410729449c3ba63c921bae04042e8c2c09c15053c11e9"});
package/worker/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {drainSince,isAdmission,drainResponse} from './move-drain.mjs';
2
+ import {managedMedia} from './managed-media.mjs';
1
3
  /** Studio Worker event boundaries and public toolkit exports. */
2
4
  import { catalogue } from './catalogue.mjs';
3
5
  import {trackBackground,requestDatabase,MUTATION_VERSION} from './request-db.mjs';
@@ -11,6 +13,7 @@ import { appCors, isAppOrigin } from './standalone.mjs';
11
13
 
12
14
 
13
15
  import {json,finish} from './responses.mjs';
16
+ import {isReadOnly,readOnlyAccess,readOnlyDatabase,readOnlyResponse,markReadOnly,heldBatch,ReadOnlyError} from './read-only.mjs';
14
17
 
15
18
  export {useRoutes} from './custom-routes.mjs';
16
19
  export {isOwner,hasRole,requireSameOrigin} from './route-auth.mjs';
@@ -30,10 +33,17 @@ export {AppRecordHub, EventHub} from './stream-objects.mjs';
30
33
  export {directoryOf,useTools,getStudioTools} from './tool-registry.mjs';
31
34
  const route = async (...args) => (await import('./routes.mjs')).default(...args);
32
35
  export default {
33
- async queue(batch,env) {return (await import('./background-queue.mjs')).consumeBackground(batch,await loadShopLinks(env));},
36
+ async queue(batch,env) {
37
+ // Read-only (worker/read-only.mjs): Stripe events are fulfilled now, the rest is retried after the window.
38
+ if(isReadOnly(env))batch=heldBatch(batch);
39
+ if(!batch.messages.length)return;
40
+ return (await import('./background-queue.mjs')).consumeBackground(batch,await loadShopLinks(env));
41
+ },
34
42
  async scheduled(event, env, ctx) {
35
43
  env=await loadShopLinks(env);
36
- const analytics=event.cron === '17 * * * *' ? (await import('./analytics.mjs')).rollupAnalytics(env) : Promise.resolve();
44
+ const readOnly=isReadOnly(env);
45
+ if(readOnly)console.log(JSON.stringify({at:new Date().toISOString(),ev:'scheduled-read-only',cron:event.cron,why:'the studio is read-only: this tick only recovers Stripe events'}));
46
+ const analytics=event.cron === '17 * * * *' && !readOnly ? (await import('./analytics.mjs')).rollupAnalytics(env) : Promise.resolve();
37
47
  ctx.waitUntil(analytics);
38
48
  const maintenance=(async () => {
39
49
  const [{notifyTools},{reconcileOrders,shopOf}]=await Promise.all([import('./runtime-support.mjs'),import('./shop.mjs')]);
@@ -49,17 +59,27 @@ export default {
49
59
  ['Stripe recovery',async()=>{if(env.BACKGROUND && event.cron==='17 * * * *')await (await import('./stripe-intake.mjs')).sweepStripeEvents(env,cat);}],
50
60
  ['owner functions',async()=> (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid')],
51
61
  ];
52
- await Promise.all(tasks.map(async([name,run])=>{try{await run();}catch(error){console.error('Scheduled '+name+' failed:',error.message);}}));
62
+ // Read-only: only payment fulfilment runs; every other task does its work on its next tick.
63
+ await Promise.all(tasks.filter(([name])=>!readOnly||name==='Stripe recovery').map(async([name,run])=>{try{await run();}catch(error){console.error('Scheduled '+name+' failed:',error.message);}}));
53
64
  })();
54
65
  ctx.waitUntil(maintenance);
55
66
  await Promise.all([maintenance,analytics]);
56
67
  },
57
68
  async fetch(request, env, ctx) {
69
+ env=managedMedia(env);
70
+ if(isAdmission(request)&&await drainSince(env))return drainResponse();
58
71
  const staticPath=new URL(request.url).pathname;
59
72
  if(['GET','HEAD'].includes(request.method)&&staticPath==='/_homie/site.js')return finish(await route(request,env,ctx),staticPath);
60
73
  env=await loadShopLinks(env,new URL(request.url).origin);
61
- env={...env,DB:requestDatabase(env.DB,request,{mutated:false,summaries:Boolean(env.BACKGROUND)})};
62
74
  const url = new URL(request.url);
75
+ const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
76
+
77
+ // Read-only (worker/read-only.mjs): a change is refused before anything runs; a read gets a D1 that refuses writes.
78
+ const readOnly=isReadOnly(env),access=readOnly?readOnlyAccess(request,url.pathname):'write';
79
+ if(access==='refuse')return appRecords?appCors(request,readOnlyResponse()):readOnlyResponse();
80
+ let refusedWrite=false;
81
+ env={...env,DB:requestDatabase(env.DB,request,{mutated:false,summaries:Boolean(env.BACKGROUND)})};
82
+ if(access==='read')env={...env,DB:readOnlyDatabase(env.DB,()=>{refusedWrite=true;})};
63
83
  if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
64
84
  if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
65
85
  const dispatch=()=>import('./background-queue.mjs').then(m=>m.enqueueChanges(env,url.origin));
@@ -81,14 +101,16 @@ export default {
81
101
  const requestCtx=background.context;
82
102
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
83
103
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
84
- const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
85
104
  if (appRecords && request.method === 'OPTIONS') return appCors(request, new Response(null, { status: 204, headers: { 'access-control-allow-methods': 'GET, POST, PUT, DELETE', 'access-control-allow-headers': 'content-type' } }));
86
105
  const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
87
106
  let res;
88
107
  try { res = finish(await route(request, env, requestCtx), url.pathname); } catch (error) {
89
- if (!appLobby) throw error;
90
- try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
91
- res = json({ ok: false, error: 'failed' }, 500);
108
+ if (error instanceof ReadOnlyError) res = readOnlyResponse();
109
+ else if (!appLobby) throw error;
110
+ else {
111
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
112
+ res = json({ ok: false, error: 'failed' }, 500);
113
+ }
92
114
  } finally {
93
115
  // A route can commit a write and then fail. Its outbox still needs delivery.
94
116
  if(env.DB) {
@@ -108,6 +130,9 @@ export default {
108
130
  }
109
131
  res=new Response(body,{status:res.status,headers});
110
132
  }
133
+ // A read that needed its write, and failed for want of it, says why.
134
+ if(readOnly&&refusedWrite&&res.status>=500)res=readOnlyResponse();
135
+ if(readOnly)res=await markReadOnly(res);
111
136
  const bookmark=env.DB?.getBookmark?.();
112
137
  if(bookmark&&res.status!==101){const headers=new Headers(res.headers);headers.set('x-studio-bookmark',bookmark);res=new Response(res.body,{status:res.status,statusText:res.statusText,headers});}
113
138
  if (appLobby || appRecords) return appCors(request, res);