@homie-rocks/studio 0.58.7 → 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 +46 -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 +84 -0
- package/package.json +1 -1
- package/starters/block-drop/README.md +2 -0
- package/starters/block-drop/game.json +1 -1
- package/starters/block-drop/index.html +1 -1
- package/starters/block-drop/src/view.ts +34 -8
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +36 -9
- 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/lib/port-check.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 = [];
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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({
|
|
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
|
|
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.
|
|
@@ -4470,6 +4470,90 @@
|
|
|
4470
4470
|
}
|
|
4471
4471
|
}
|
|
4472
4472
|
}
|
|
4473
|
+
},
|
|
4474
|
+
"0.58.8": {
|
|
4475
|
+
"files": {
|
|
4476
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4477
|
+
"README.md": "684b3cca8001673c",
|
|
4478
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4479
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4480
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4481
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4482
|
+
"posts/README.md": "37841aae476bd540",
|
|
4483
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4484
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4485
|
+
},
|
|
4486
|
+
"sections": {
|
|
4487
|
+
"AGENTS.md": {
|
|
4488
|
+
"(intro)": "af7f2f732505d379",
|
|
4489
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4490
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4491
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4492
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4493
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4494
|
+
"## Making games": "bb715272fd1a615b",
|
|
4495
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4496
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4497
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4498
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4499
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4500
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4501
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4502
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4503
|
+
"## Beta": "9d02638207a26e73"
|
|
4504
|
+
}
|
|
4505
|
+
},
|
|
4506
|
+
"named": {
|
|
4507
|
+
"Homie Arcade": {
|
|
4508
|
+
"sections": {
|
|
4509
|
+
"AGENTS.md": {
|
|
4510
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
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
|
+
}
|
|
4473
4557
|
}
|
|
4474
4558
|
}
|
|
4475
4559
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.11",
|
|
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",
|
|
@@ -10,3 +10,5 @@ Start here for a falling-block puzzle. It already plays: preserve the working me
|
|
|
10
10
|
Build and repair the exact diagnostic before adding more. A file write replaces its entire contents; use targeted edits for small repairs. After an interrupted edit, read the file before continuing.
|
|
11
11
|
|
|
12
12
|
Exercise left/right, rotation, soft and hard drop on keyboard and with real touchscreen taps. Play two independent wells, clear a row, top out, finish/restart a round and join a bot's seat. Inspect computer, phone and sideways-phone frames. A green movement probe or compiler alone does not prove a board game plays.
|
|
13
|
+
|
|
14
|
+
The room uses `rest: "quiet"`: gravity and the round clock keep running while a connected person watches. Do not use the movement starters' away-from-keyboard `idle` pause for this continuous falling-block game. Empty rooms still stop normally.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id":"block-drop","name":"Block Drop","blurb":"Seven shapes. One well. Stack, rotate and clear lines before the clock runs out.",
|
|
3
|
-
"players":{"min":1,"max":4},"entry":"src/view.ts","room":{"host":"server","rest":"
|
|
3
|
+
"players":{"min":1,"max":4},"entry":"src/view.ts","room":{"host":"server","rest":"quiet"},
|
|
4
4
|
"playtest":{"primary":{"label":"hard drop","computer":{"key":"Space"},"phone":{"selector":"[data-action=drop]"}}},
|
|
5
5
|
"screen":{"share":{"desk":"top-right","phone":"top-right"},"chat":{"lines":"sheet-only"}},
|
|
6
6
|
"landing":{"pitch":"Find the gap. Make the line.","about":"A falling-block puzzle with a separate well for every player. Seven-piece bags, ghost pieces, wall kicks and line-clear scoring. Play with a friend or a bot in two-minute rounds.","howToPlay":["Move and rotate each falling shape into a gap.","Fill a horizontal row to clear it. Clear four at once for the biggest score.","Use the ghost outline to aim a hard drop. Keep your stack below the top."],"controls":{"phone":"Tap the arrows to move, ↻ to rotate and DROP to place the piece.","computer":"Left/right to move, up to rotate, down for soft drop and space for hard drop.","tv":"Watch every well; each player uses their own phone."},"credits":[{"role":"Starter","name":"Homie · Block Drop"}]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Block Drop</title><link rel="icon" href="data:,"><style>
|
|
3
|
-
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#060b16;overflow:hidden;overscroll-behavior:none;font-family:system-ui;color:#edf5ff}canvas{position:fixed;inset:0;width:100%;height:100%;touch-action:none}#controls{position:fixed;bottom:max(
|
|
3
|
+
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#060b16;overflow:hidden;overscroll-behavior:none;font-family:system-ui;color:#edf5ff}canvas{position:fixed;inset:0;width:100%;height:100%;touch-action:none}#controls{position:fixed;bottom:max(44px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);display:flex;gap:7px}button{width:55px;height:49px;border:1px solid #92b6e34d;border-radius:12px;background:#172741;color:#eaf4ff;font:600 19px system-ui;touch-action:none;user-select:none;-webkit-user-select:none;box-shadow:0 4px 0 #030713}button[data-action=drop]{width:70px;font-size:12px;background:#185950;border-color:#68e9cc66}button.pressed,button:active{transform:translateY(3px);background:#398b89;box-shadow:none}button[data-action=audio]{width:38px;font-size:16px}button:focus-visible{outline:2px solid #fff;outline-offset:3px}#hint{position:fixed;bottom:102px;left:0;right:0;text-align:center;font:10px system-ui;color:#a3b5ce;pointer-events:none}@media(max-width:600px){#hint{display:none}}@media(max-height:460px) and (min-width:600px){button{width:48px;height:44px}button[data-action=drop]{width:58px}#controls{left:14px;top:100px;bottom:auto;transform:none;display:grid;grid-template-columns:58px 58px;gap:9px}#hint{display:none}}
|
|
4
4
|
</style></head><body><canvas></canvas><div id="hint">← → move · ↑ rotate · ↓ soft drop · SPACE hard drop</div><div id="controls" aria-label="Game controls"><button data-action="left" aria-label="Move left">←</button><button data-action="rotate" aria-label="Rotate">↻</button><button data-action="soft" aria-label="Soft drop">↓</button><button data-action="right" aria-label="Move right">→</button><button data-action="drop" aria-label="Hard drop">DROP</button><button data-action="audio" aria-label="Sound" aria-pressed="true">♪</button></div><script type="module" crossorigin src="./assets/main.js"></script></body></html>
|
|
@@ -25,7 +25,7 @@ function tone(hz: number, seconds = 0.055, volume = 0.055): void {
|
|
|
25
25
|
if (!audio || audio.state !== 'running' || muted) return;
|
|
26
26
|
const o = audio.createOscillator(), gain = audio.createGain();
|
|
27
27
|
o.type = 'triangle'; o.frequency.value = hz;
|
|
28
|
-
gain.gain.setValueAtTime(volume, audio.currentTime);
|
|
28
|
+
gain.gain.setValueAtTime(volume * 3, audio.currentTime);
|
|
29
29
|
gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + seconds);
|
|
30
30
|
o.connect(gain).connect(audio.destination); o.start(); o.stop(audio.currentTime + seconds);
|
|
31
31
|
}
|
|
@@ -52,7 +52,15 @@ for (const button of document.querySelectorAll<HTMLButtonElement>('[data-action]
|
|
|
52
52
|
if (action === 'drop') drop = true;
|
|
53
53
|
if (action === 'audio') { muted = !muted; button.textContent = muted ? '×♪' : '♪'; button.setAttribute('aria-pressed', String(!muted)); }
|
|
54
54
|
});
|
|
55
|
-
|
|
55
|
+
button.addEventListener('pointermove', e => {
|
|
56
|
+
if (!button.hasPointerCapture(e.pointerId) || !['left','right','soft'].includes(action ?? '')) return;
|
|
57
|
+
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLButtonElement>('[data-action]');
|
|
58
|
+
const next = target?.dataset.action;
|
|
59
|
+
touchDx = next === 'left' ? -1 : next === 'right' ? 1 : 0; soft = next === 'soft';
|
|
60
|
+
for (const control of document.querySelectorAll<HTMLButtonElement>('[data-action]'))
|
|
61
|
+
control.classList.toggle('pressed', control === target && ['left','right','soft'].includes(next ?? ''));
|
|
62
|
+
});
|
|
63
|
+
const release = (): void => { for (const control of document.querySelectorAll('[data-action]')) control.classList.remove('pressed'); if (['left','right','soft'].includes(action ?? '')) { touchDx = 0; soft = false; } };
|
|
56
64
|
button.addEventListener('pointerup', release); button.addEventListener('pointercancel', release);
|
|
57
65
|
// Keyboard activation for focused native buttons; pointer input is handled above.
|
|
58
66
|
button.addEventListener('click', e => { if (e.detail === 0) { unlock(); if (action === 'rotate') rotate = true; if (action === 'drop') drop = true; } });
|
|
@@ -91,15 +99,15 @@ function frame(): void {
|
|
|
91
99
|
if (audio?.state === 'running' && now >= nextNote) { const hz = melody[note++ % melody.length]; if (hz) tone(hz, 0.21, 0.075); nextNote = now + 250; }
|
|
92
100
|
room.input({ dx: touchDx || (keys.has('ArrowRight') || keys.has('KeyD') ? 1 : 0) - (keys.has('ArrowLeft') || keys.has('KeyA') ? 1 : 0), soft: soft || keys.has('ArrowDown') || keys.has('KeyS'), rotate, drop }); rotate = false; drop = false;
|
|
93
101
|
const bg = g.createRadialGradient(w * 0.5, h * 0.3, 10, w * 0.5, h * 0.5, Math.max(w, h)); bg.addColorStop(0, '#172747'); bg.addColorStop(1, '#060b16'); g.fillStyle = bg; g.fillRect(0, 0, w, h);
|
|
94
|
-
text(document.title.toUpperCase(), 20,
|
|
102
|
+
text(document.title.toUpperCase(), 20, 61, 18, '#eef6ff');
|
|
95
103
|
const round = room.round;
|
|
96
|
-
text(round?.phase === 'over' ? `Next round in ${Math.ceil(round.secondsLeft)}` : round ? `${Math.ceil(round.secondsLeft)}s` : 'Joining…', 20,
|
|
104
|
+
text(round?.phase === 'over' ? `Next round in ${Math.ceil(round.secondsLeft)}` : round ? `${Math.ceil(round.secondsLeft)}s` : 'Joining…', 20, 80, 12);
|
|
97
105
|
const boards: Board[] = []; room.each('board', b => boards.push(b));
|
|
98
106
|
const me = room.me; const focus = me ?? boards[0];
|
|
99
107
|
if (focus) {
|
|
100
|
-
const compact = w < 640, top =
|
|
108
|
+
const compact = w < 640, landscape = w >= 640 && h <= 460, top = landscape ? 58 : 108, bottom = compact ? 174 : landscape ? 18 : 110;
|
|
101
109
|
const s = Math.max(5, Math.min(32, (h - top - bottom) / 20, (w - (compact ? 116 : 330)) / 10));
|
|
102
|
-
const total = 10 * s + 98 + (!compact && boards.length > 1 ? 150 : 0);
|
|
110
|
+
const total = 10 * s + 98 + (!compact && !landscape && boards.length > 1 ? 150 : 0);
|
|
103
111
|
const x = Math.max(12, (w - total) / 2), y = top;
|
|
104
112
|
const roster = room.roster.find(r => r.seat === focus.seat);
|
|
105
113
|
text(me ? 'YOUR WELL' : (roster?.name ?? 'WATCHING'), x, y - 13, 11, '#7aeee1');
|
|
@@ -111,7 +119,7 @@ function frame(): void {
|
|
|
111
119
|
text('SCORE', side, y + 110, 10); text(String(focus.score), side, y + 136, 21, '#f5f8ff');
|
|
112
120
|
if (h >= 350) { text('LINES', side, y + 170, 10); text(String(focus.lines), side, y + 194, 19, '#f5f8ff'); }
|
|
113
121
|
if (20 * s > 260) { text('LEVEL', side, y + 230, 10); text(String(1 + Math.floor(focus.lines / 10)), side, y + 254, 19, '#f5f8ff'); }
|
|
114
|
-
if (!compact) {
|
|
122
|
+
if (!compact && !landscape) {
|
|
115
123
|
let ry = y;
|
|
116
124
|
for (const rival of boards.filter(b => b.id !== focus.id).slice(0, 3)) {
|
|
117
125
|
const mini = Math.min(6, s * 0.25), rx = side + 112;
|
|
@@ -120,7 +128,25 @@ function frame(): void {
|
|
|
120
128
|
well(rival, rx, ry, mini, false); text(String(rival.score), rx + 10 * mini + 9, ry + 14, 12, '#eef6ff'); ry += 20 * mini + 38;
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
|
-
|
|
131
|
+
const ranked = boards.slice().sort((a, b) => b.score - a.score || a.seat - b.seat);
|
|
132
|
+
const nameOf = (b: Board): string => { const r = room.roster.find(r => r.seat === b.seat); return b.id === me?.id ? 'YOU' : `${r?.driver === 'bot' ? 'BOT ' : ''}${r?.name ?? 'Player'}`; };
|
|
133
|
+
const rank = ranked.findIndex(b => b.id === focus.id) + 1;
|
|
134
|
+
if (compact) {
|
|
135
|
+
text(`RANK ${rank}/${ranked.length} · LEADER ${ranked[0]?.score ?? 0}`, 16, h - 155, 12, '#7aeee1');
|
|
136
|
+
ranked.slice(0, 4).forEach((b, i) => text(`${i + 1}. ${nameOf(b).slice(0, 12)} ${b.score}`, 16 + (i % 2) * (w / 2), h - 132 + Math.floor(i / 2) * 19, 11, b.id === me?.id ? '#f5f8ff' : '#a7b8d5'));
|
|
137
|
+
} else if (landscape) {
|
|
138
|
+
text(`RANK ${rank}/${ranked.length}`, side + 112, y + 142, 12, '#7aeee1');
|
|
139
|
+
ranked.slice(0, 4).forEach((b, i) => text(`${i + 1}. ${nameOf(b).slice(0, 10)} ${b.score}`, side + 112, y + 15 + i * 24, 12, b.id === me?.id ? '#f5f8ff' : '#a7b8d5'));
|
|
140
|
+
}
|
|
141
|
+
if (round?.phase === 'over') {
|
|
142
|
+
const results = round.results?.length ? round.results : ranked.map((b, i) => ({ name: nameOf(b), score: b.score, seat: b.seat, place: i + 1 }));
|
|
143
|
+
const pw = Math.min(340, w - 32), ph = 110 + results.length * 27, px = (w - pw) / 2, py = Math.max(42, (h - ph) / 2);
|
|
144
|
+
g.fillStyle = '#081329f5'; g.fillRect(px, py, pw, ph); g.strokeStyle = '#71e9e0'; g.strokeRect(px, py, pw, ph);
|
|
145
|
+
text(`${results[0]?.name ?? 'Player'} WINS`, px + 16, py + 30, 18, '#ffd75b');
|
|
146
|
+
text('FINAL SCORES', px + 16, py + 55, 11);
|
|
147
|
+
results.forEach((r, i) => text(`${r.place}. ${r.name.slice(0, 18)}${r.seat === me?.seat ? ' (YOU)' : ''} ${r.score}`, px + 16, py + 82 + i * 27, 13, '#f5f8ff'));
|
|
148
|
+
text(`Next round in ${Math.ceil(round.secondsLeft)}s`, px + 16, py + ph - 15, 11);
|
|
149
|
+
}
|
|
124
150
|
} else text('Finding your seat…', Math.max(20, w / 2 - 70), h / 2, 15);
|
|
125
151
|
requestAnimationFrame(frame);
|
|
126
152
|
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.11","sha256":"4b6a276c704c1647234dfdbef1628bff5ade43b918653552b493965a2db72f9b"});
|
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) {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
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) {
|
|
@@ -99,13 +121,18 @@ export default {
|
|
|
99
121
|
if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
|
|
100
122
|
const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
|
|
101
123
|
let body=res.body;
|
|
102
|
-
|
|
124
|
+
// The outer page labels the whole preview. Repeating that overlay in an
|
|
125
|
+
// embedded game/app obscures its HUD and counts as the game's own UI.
|
|
126
|
+
if(request.headers.get('sec-fetch-dest')!=='iframe' && /text\/html/i.test(headers.get('content-type')??'')) {
|
|
103
127
|
const html=await res.text(),banner='<aside role="status" style="position:fixed;z-index:2147483647;top:0;left:0;right:0;text-align:center;background:#ffda44;color:#171717;padding:6px;font:bold 14px system-ui;pointer-events:none">TEST / CONCEPT</aside>';
|
|
104
128
|
body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
|
|
105
129
|
headers.delete('content-length');
|
|
106
130
|
}
|
|
107
131
|
res=new Response(body,{status:res.status,headers});
|
|
108
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);
|
|
109
136
|
const bookmark=env.DB?.getBookmark?.();
|
|
110
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});}
|
|
111
138
|
if (appLobby || appRecords) return appCors(request, res);
|
package/worker/lobby.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import {LobbyPeak} from './lobby-peak.mjs';
|
|
|
2
2
|
import {MatchRouter} from './match-router.mjs';
|
|
3
3
|
import {LOBBY_SHARDS,SHARDED_ROOM_BASE} from './lobby-shards.mjs';
|
|
4
4
|
import {json} from './responses.mjs';
|
|
5
|
+
import {readOnlyEnv} from './read-only.mjs';
|
|
5
6
|
import {lobbyStore} from './lobby-store.mjs';
|
|
6
7
|
import { errorLine, versionOf } from './room-protocol.mjs';
|
|
7
8
|
import { ROOM_ID } from './page-policy.mjs';
|
|
@@ -16,7 +17,7 @@ export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
|
|
|
16
17
|
export class Lobby {
|
|
17
18
|
constructor(ctx, env) {
|
|
18
19
|
this.ctx = ctx;
|
|
19
|
-
this.env = env;
|
|
20
|
+
this.env = readOnlyEnv(env); // read-only: matchmaking goes on, its D1 counters wait (worker/read-only.mjs)
|
|
20
21
|
this.store = lobbyStore(ctx.storage);
|
|
21
22
|
this.rooms = new Map();
|
|
22
23
|
this.reservations=new Map();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Hydrate the R2-compatible metadata returned by the studio-scoped managed binding.
|
|
2
|
+
* Streams cross RPC without buffering songs, videos or saves in the Worker heap. */
|
|
3
|
+
function object(value) {
|
|
4
|
+
if(!value)return null;
|
|
5
|
+
return {...value,
|
|
6
|
+
writeHttpMetadata(headers){for(const [key,name] of Object.entries({contentType:'content-type',contentLanguage:'content-language',contentDisposition:'content-disposition',contentEncoding:'content-encoding',cacheControl:'cache-control',cacheExpiry:'expires'})){if(value.httpMetadata?.[key])headers.set(name,String(value.httpMetadata[key]));}},
|
|
7
|
+
arrayBuffer:()=>new Response(value.body).arrayBuffer(),
|
|
8
|
+
text:()=>new Response(value.body).text(),
|
|
9
|
+
json:()=>new Response(value.body).json(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function managedMedia(env) {
|
|
13
|
+
if(!env.HOMIE_MEDIA)return env;
|
|
14
|
+
const binding=env.HOMIE_MEDIA;
|
|
15
|
+
return {...env,MEDIA:{get:async(key,options)=>object(await binding.get(key,options)),head:async key=>object(await binding.head(key)),put:async(key,body,options)=>object(await binding.put(key,body,options)),delete:keys=>binding.delete(keys)}};
|
|
16
|
+
}
|