@homie-rocks/studio 0.54.0 → 0.55.0

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/lib/background-queue.mjs +31 -0
  3. package/lib/build.mjs +4 -1
  4. package/lib/cloudflare.mjs +16 -1
  5. package/lib/read-replication.mjs +26 -0
  6. package/lib/scaffold.mjs +28 -10
  7. package/lib/shop.mjs +7 -0
  8. package/lib/static-assets.mjs +27 -0
  9. package/lib/template-history.json +42 -0
  10. package/lib/worker-build.mjs +13 -4
  11. package/lib/worker-config.mjs +3 -1
  12. package/package.json +1 -1
  13. package/worker/analytics.mjs +84 -0
  14. package/worker/app-frame.mjs +1 -1
  15. package/worker/app-graphics.mjs +1 -1
  16. package/worker/app-streams.mjs +3 -3
  17. package/worker/asset-paths.mjs +2 -0
  18. package/worker/background-queue.mjs +30 -0
  19. package/worker/build-id.mjs +1 -1
  20. package/worker/built-metadata.mjs +2 -0
  21. package/worker/catalogue.mjs +18 -0
  22. package/worker/chat-page.mjs +1 -1
  23. package/worker/embed.mjs +1 -1
  24. package/worker/event-resources.mjs +23 -11
  25. package/worker/function-registry.mjs +4 -0
  26. package/worker/functions.mjs +7 -5
  27. package/worker/gate-routing.mjs +61 -0
  28. package/worker/gate.mjs +12 -68
  29. package/worker/hosted-registry.mjs +11 -0
  30. package/worker/hosted.mjs +3 -17
  31. package/worker/index.mjs +54 -2785
  32. package/worker/lazy-objects.mjs +40 -0
  33. package/worker/lobby-limits.mjs +2 -0
  34. package/worker/lobby.mjs +251 -0
  35. package/worker/lounge.mjs +3 -3
  36. package/worker/mcp-event-streams.mjs +3 -3
  37. package/worker/mcp-events.mjs +4 -4
  38. package/worker/mcp-store.mjs +12 -1
  39. package/worker/mcp.mjs +5 -1
  40. package/worker/office.mjs +16 -20
  41. package/worker/operational-schema.mjs +14 -0
  42. package/worker/page-policy.mjs +29 -0
  43. package/worker/pages.mjs +8 -28
  44. package/worker/parts.mjs +2 -1
  45. package/worker/payment-recovery.mjs +33 -19
  46. package/worker/player-registry.mjs +9 -0
  47. package/worker/players.mjs +8 -7
  48. package/worker/purchase-machine.mjs +5 -3
  49. package/worker/reconcile-guard.mjs +22 -0
  50. package/worker/request-db.mjs +35 -10
  51. package/worker/responses.mjs +18 -0
  52. package/worker/room-chat.mjs +216 -0
  53. package/worker/room-controls.mjs +296 -0
  54. package/worker/room-lifecycle.mjs +10 -0
  55. package/worker/room-protocol.mjs +197 -0
  56. package/worker/room.mjs +38 -731
  57. package/worker/rooms-cache.mjs +19 -0
  58. package/worker/routes.mjs +667 -0
  59. package/worker/runtime-support.mjs +445 -0
  60. package/worker/seats.mjs +1 -1
  61. package/worker/selling/index.mjs +1 -0
  62. package/worker/selling/runtime.mjs +7 -5
  63. package/worker/service-run.mjs +1 -1
  64. package/worker/shop-links.mjs +1 -1
  65. package/worker/shop-page.mjs +1 -0
  66. package/worker/shop.mjs +27 -20
  67. package/worker/stats.mjs +7 -6
  68. package/worker/stream-objects.mjs +18 -0
  69. package/worker/stripe-intake.mjs +79 -0
  70. package/worker/stripe.mjs +3 -0
  71. package/worker/table.mjs +1212 -0
  72. package/worker/tool-registry.mjs +10 -0
  73. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,33 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.55.0] - 2026-10-15
21
+
22
+ **Plugin 0.55.0**
23
+
24
+ Studios start faster and serve files straight from Cloudflare, background work and Stripe events go through retrying queues so nothing is lost, and the runtime follows Cloudflare's published practice.
25
+
26
+ ### Changed
27
+
28
+ - Deploy with D1 primary fallback when replication is unavailable; only install needed schedules, explain account cron limits, and preserve retired-source, HEAD and concept-page protections.
29
+ - Deliver mutations and verified Stripe webhooks through retryable Queues. Store PaymentIntent ids before follow-up, recover lost responses using the original idempotency key, and retain uncertain payments for review.
30
+ - Batch cold metadata, use causally consistent D1 sessions, bake catalogues, route public assets first, isolate subscription objects and load rendering modules at their entry points. Index MCP expiry and deliver catalogue notifications in background work.
31
+ - Split request routing, runtime helpers, Table, Lobby, room chat, controls and wire validation into modules without changing behavior.
32
+ - Skip unchanged analytics counters and watermarks, paginate rollups by UTC day, and show delayed counts in owner statistics.
33
+ - Set `stats.analytics: true` in studio.json to count page activity with Analytics Engine and hourly, replay-safe D1 rollups that preserve historical totals. Owner statistics explain ingestion lag and sampling.
34
+
35
+ ### Fixed
36
+
37
+ - Weight Analytics Engine page counts by the sample interval, preserving historical totals and retry-safe corrections under sampling.
38
+ - Stop queued Stripe reconciliation database operations and provider calls after lease renewal fails or the lease expires, leaving the event pending for retry.
39
+ - Preserve live MCP OAuth and webhook dedupe rows during cleanup by using Unix seconds throughout MCP storage; reclaim idle guests occasionally on guest creation even without a studio cron.
40
+ - Recover verified Stripe intake from D1 after queue failures, renew reconciliation leases, preserve replay API versions, report stuck deliveries, and isolate scheduled maintenance failures. Resume partial queue setup with clear CI token permission guidance.
41
+ - Confine Chrome debugging pipes to CI with protocol deadlines matching launch deadlines; preserve owner CLI flags and capture child stderr, exits, shared-memory availability and concurrent Chrome processes in CI artifacts.
42
+
43
+ ### Upgrade notes
44
+
45
+ - Existing restricted Stripe keys need Products and Prices Read for expanded checkout/subscription lines. Shop-only keys also need Subscriptions Read, Invoices Read and Customer portal Write for renewal verification and subscription management. Update both test and live keys in Stripe before deploying. Expanded objects require their own permissions: https://docs.stripe.com/stripe-apps/reference/permissions.
46
+
20
47
  ## [0.54.0] - 2026-10-15
21
48
 
22
49
  **Plugin 0.54.0**
@@ -0,0 +1,31 @@
1
+ import {readStudio,writeStudio,workerDir} from './studio.mjs';
2
+ import {cloudflareAuth} from './brain-probe.mjs';
3
+ import {join} from 'node:path';
4
+ export const backgroundQueues=worker=>({work:worker.slice(0,48)+'-background',dead:worker.slice(0,48)+'-background-dlq'});
5
+ /** Names reserve this studio's queues. An interrupted pre-deploy creation has no
6
+ * bindings yet; inspect structured API records before resuming that reservation. */
7
+ export async function ensureBackgroundQueues(root,run,{accountId,fetchFn=fetch,headers}={}) {
8
+ const studio=readStudio(root),names=backgroundQueues(studio.cloudflare.worker);
9
+ accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare.accountId;
10
+ for(const name of [names.dead,names.work]) {
11
+ if(readStudio(root).cloudflare.created?.includes('queue:'+name))continue;
12
+ const result=run(['queues','create',name]);
13
+ if(result.code!==0) {
14
+ headers??=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
15
+ const permission='The deployment token needs Account > Queues > Edit on this account. Add that permission to the CI/Workers Builds token (or sign in again with Wrangler), then retry deploy.';
16
+ if(!headers||!accountId)throw new Error(permission);
17
+ const response=await fetchFn(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/queues?name=${encodeURIComponent(name)}`,{headers,signal:AbortSignal.timeout(15000)});
18
+ if(response.status===401||response.status===403)throw new Error(permission);
19
+ const body=await response.json();
20
+ if(!response.ok||body.success===false)throw new Error(`Could not inspect queue ${name} (HTTP ${response.status}). ${permission}`);
21
+ const queue=body.result?.find(q=>q.queue_name===name);
22
+ if(!queue)throw new Error(`Could not create queue ${name}. ${permission} Wrangler: ${result.out?.trim().split('\n').slice(-4).join(' ')}`);
23
+ const producers=queue.producers??[],consumers=queue.consumers??[];
24
+ if((queue.producers_total_count??producers.length)>producers.length||(queue.consumers_total_count??consumers.length)>consumers.length||
25
+ producers.some(p=>p.type!=='worker'||p.script!==studio.cloudflare.worker)||consumers.some(c=>c.type!=='worker'||(c.script_name??c.script)!==studio.cloudflare.worker))
26
+ throw new Error(`Queue ${name} is bound to another producer or consumer; it must belong exclusively to this studio`);
27
+ }
28
+ const latest=readStudio(root);
29
+ writeStudio(root,{...latest,cloudflare:{...latest.cloudflare,created:[...new Set([...(latest.cloudflare.created??[]),'queue:'+name])].sort()}});
30
+ }
31
+ }
package/lib/build.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {staticHeaders} from './static-assets.mjs';
1
2
  import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
3
  import { directoryUrl } from '../worker/directory-choice.mjs';
3
4
  import { domainOrigin } from './studio.mjs';
@@ -741,6 +742,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
741
742
  const image = await playerImage(catalogue, game, dist, log);
742
743
  if (image) game.playerImage = image;
743
744
  }
745
+ const headerPath = join(dist, '_headers');
746
+ writeFileSync(headerPath, staticHeaders(existsSync(headerPath) ? readFileSync(headerPath, 'utf8') : '', {concept: studio.concept === true}));
744
747
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
745
748
  // What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
746
749
  // site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
@@ -761,7 +764,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
761
764
  await beforePublish();
762
765
  const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
763
766
  log('Building site Worker…');
764
- await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
767
+ await (await import('./worker-build.mjs')).buildWorker(root, esbuild, { catalogue, posts: posts.map(p => ({ ...p })) });
765
768
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
766
769
  const swapped = swapIn(dist, live);
767
770
  return {
@@ -1,3 +1,5 @@
1
+ import {enableReadReplication} from './read-replication.mjs';
2
+ import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
1
3
  import { directoryUrl } from '../worker/directory-choice.mjs';
2
4
  import { roomLayout } from '../worker/gate.mjs';
3
5
  import {workerEntry} from './worker-build.mjs';
@@ -101,6 +103,12 @@ function parseJson(text) {
101
103
  export function explainCloudflare(out, accountId = null) {
102
104
  const text = String(out ?? '');
103
105
  const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
106
+ if (/queue.*(?:permission|authentication|forbidden|unauthori)|(?:permission|authentication|forbidden|unauthori).*queue/is.test(text)) {
107
+ return {needs:'queues-permission',why:'The deployment token needs Account > Queues > Edit on this account. Update the CI/Workers Builds token permissions, then retry deploy.'};
108
+ }
109
+ if (/(?:cron|schedule).*?(?:limit|maximum|exceed)|(?:limit|maximum|exceed).*?(?:cron|schedule)/i.test(text)) {
110
+ return { needs: 'cron-limit', why: 'This account has reached its Cron Trigger limit (5 on Workers Free). Remove unused triggers from other Workers in the Cloudflare dashboard, or upgrade the account plan, then retry deploy. Required studio schedules have been retained.' };
111
+ }
104
112
  if (/\b10034\b|verify your email/i.test(text)) {
105
113
  return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it. The AI resumes deployment after verification.` };
106
114
  }
@@ -210,6 +218,9 @@ export function deployPlan(root) {
210
218
  { kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
211
219
  ...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `One full ${h.seats}-player room activates ${h.objects} objects (${h.layout.gates > 1 ? `${h.layout.gates} Gates, the room and its diagnostics` : 'the room'}): approximately ${h.gbSeconds} GB-seconds/hour before Lobby activity. Player input/ping alone contributes ${h.requests} request units/hour; relay frames and receipts, HTTP and storage are additional. Occupancy, batching and account usage determine the bill.`, state: 'in the Worker' })),
212
220
  { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker' },
221
+ ...['Gate','Concentrator','AppRecordHub','EventHub'].map(name=>({kind:'Durable Object',name,what:name==='Gate'?'room transport and diagnostics':name==='Concentrator'?'compatibility for existing studios':'independent live subscriptions',state:'declared by the Worker'})),
222
+ ...Object.values(backgroundQueues(cf.worker)).map(name=>({kind:'Queue',name,what:'durable background delivery with retries and a dead-letter queue',state:mark('queue:'+name)})),
223
+ ...(studio.stats?.analytics===true ? [{kind:'Analytics Engine',name:`${cf.worker.replace(/-/g,'_')}_visits`,what:'page counts, rolled into historical D1 totals hourly',state:'bound by the Worker'}] : []),
213
224
  storage
214
225
  ? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
215
226
  : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
@@ -384,8 +395,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
384
395
  step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join("; ")||"none (remove toolkit triggers)"}`);
385
396
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
386
397
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
398
+ try {await ensureBackgroundQueues(root,w,{accountId,...(fetchFn?{fetchFn}:{})});for(const key of readStudio(root).cloudflare.created??[])created.add(key);} catch(error) {return refuse(error.message);}
387
399
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
388
400
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
401
+ try {const replication=await enableReadReplication(root,w,{accountId,databaseId:db.uuid,...(fetchFn?{fetchFn}:{})});step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
389
402
  step('D1 migrations applied');
390
403
  const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
391
404
  if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
@@ -473,7 +486,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
473
486
 
474
487
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
475
488
  const { url: _url, deployedAt: _at, ...kept } = cf;
476
- const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
489
+ const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...new Set([...created,...(readStudio(root).cloudflare.created??[])])].sort() };
477
490
  writeStudio(root, { ...readStudio(root), cloudflare: next });
478
491
  return {
479
492
  ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
@@ -604,6 +617,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
604
617
  const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
605
618
  if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
606
619
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
620
+ try {await ensureBackgroundQueues(root,w);} catch(error) {return refuse(error.message);}
607
621
  const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
608
622
  let migrate = apply();
609
623
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
@@ -633,6 +647,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
633
647
  step('D1 migrations applied (the database was created with this first deploy)');
634
648
  const registered=register();if(registered.code!==0)return refuse('Could not register studio functions',registered.out);
635
649
  }
650
+ try {const replication=await enableReadReplication(root,w);step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
636
651
  // The live address, from what Wrangler printed (a custom domain in studio.json wins).
637
652
  const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
638
653
  let url = null;
@@ -0,0 +1,26 @@
1
+ import {cloudflareAuth} from './brain-probe.mjs';
2
+ import {readConfig} from './routes.mjs';
3
+ import {readStudio,workerDir} from './studio.mjs';
4
+ import {join} from 'node:path';
5
+ /** Enable replicas on this studio's database using the same login as deployment.
6
+ * A missing account/id (first Workers Builds provisioning) is retried next deploy.
7
+ * Auth failures are reported; the primary-first runtime remains consistent.
8
+ */
9
+ export async function enableReadReplication(root, run, {accountId, databaseId, fetchFn=fetch}={}) {
10
+ const studio=readStudio(root);
11
+ accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare?.accountId;
12
+ databaseId??=readConfig(root)?.d1_databases?.find(db=>db.binding==='DB')?.database_id;
13
+ if (!accountId || !databaseId) return {enabled:false,reason:'database provisioning has not supplied its account and id'};
14
+ if (process.env.HOMIE_STUDIO_WARM==='0') return {enabled:false,reason:'offline test'};
15
+ try {
16
+ const headers=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
17
+ if (!headers) return {enabled:false,reason:'Cloudflare authentication unavailable'};
18
+ const url=`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(databaseId)}`;
19
+ const response=await fetchFn(url,{method:'PATCH',headers:{...headers,'content-type':'application/json'},body:JSON.stringify({read_replication:{mode:'auto'}}),signal:AbortSignal.timeout(15000)});
20
+ const body=await response.json();
21
+ if(!response.ok||body.success===false||body.result?.read_replication?.mode!=='auto')throw new Error(`Could not enable D1 read replication (HTTP ${response.status}); the deployment login needs D1 Edit`);
22
+ return {enabled:true};
23
+ } catch (error) {
24
+ return {enabled:false,reason:`D1 uses the primary; read replication could not be enabled: ${error.message}`};
25
+ }
26
+ }
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import {TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
1
+ import {ANALYTICS_MIGRATION,ANALYTICS_MIGRATION_FILE} from '../worker/analytics.mjs';
2
+ import {backgroundQueues} from './background-queue.mjs';
3
+ import {WORKER_ROUTES} from './static-assets.mjs';
4
+ import {EXTRA_RUNTIME_MIGRATIONS,RUNTIME_INDEX_MIGRATION,RUNTIME_INDEX_MIGRATION_FILE,TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
2
5
  import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
3
6
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
4
7
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
@@ -76,10 +79,10 @@ export function slugify(name) {
76
79
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
77
80
  * the binding names and keep it linked.
78
81
  */
79
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
82
+ export function wranglerConfig({analytics = false, workerRoutes = WORKER_ROUTES, worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
80
83
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
81
84
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
82
- const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }];
85
+ const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }, {name:'APP_RECORDS',class_name:'AppRecordHub'}, {name:'EVENTS',class_name:'EventHub'}];
83
86
  const config = {
84
87
  $schema: `${at.schema}/wrangler/config-schema.json`,
85
88
  name: worker,
@@ -92,25 +95,27 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
92
95
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
93
96
  no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
94
97
  compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
95
- ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
98
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
96
99
  workers_dev: true,
97
100
  preview_urls: true,
98
101
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
99
102
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
100
103
  // (lib/routes.mjs). A studio without routes gets no key at all.
101
104
  ...(routes?.length ? { routes } : {}),
102
- ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
103
- assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
105
+ triggers: { crons: [...new Set([...(triggers?.crons ?? []), ...(paidParts ? ['*/5 * * * *','17 * * * *'] : []), ...(analytics ? ['17 * * * *'] : [])])] },
106
+ assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: workerRoutes },
107
+ ...(analytics ? {analytics_engine_datasets:[{binding:'STUDIO_ANALYTICS',dataset:`${worker.replace(/-/g,'_')}_visits`}],analytics:{binding:'ANALYTICS_SQL'}} : {}),
108
+ queues:{producers:[{binding:'BACKGROUND',queue:backgroundQueues(worker).work},{binding:'BACKGROUND_DLQ',queue:backgroundQueues(worker).dead}],consumers:[{queue:backgroundQueues(worker).work,max_batch_size:100,max_batch_timeout:1,max_retries:5,dead_letter_queue:backgroundQueues(worker).dead}]},
104
109
  durable_objects: { bindings: rooms },
105
- migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }],
110
+ migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }, {tag:'v3-streams',new_sqlite_classes:['AppRecordHub','EventHub']}],
106
111
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
107
112
  ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
108
113
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
109
114
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
110
115
  ...(ai ? { ai: { binding: 'AI' } } : {}),
111
- vars: { STUDIO_NAME: name, ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
116
+ vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
112
117
  observability: { enabled: true },
113
- previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
118
+ previews: { vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
114
119
  };
115
120
  return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
116
121
  // the studio's Cloudflare Workers account. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
@@ -833,6 +838,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
833
838
  [`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
834
839
  [`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
835
840
  [`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
841
+ [`site/migrations/${ANALYTICS_MIGRATION_FILE}`]: ANALYTICS_MIGRATION,
842
+ [`site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`]: RUNTIME_INDEX_MIGRATION,
843
+ ...Object.fromEntries(Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>[`site/migrations/${name}`,sql])),
836
844
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
837
845
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
838
846
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1095,8 +1103,18 @@ function ensureOperational(root) {
1095
1103
  if(existsSync(file))return null;
1096
1104
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
1097
1105
  }
1106
+ function ensureRuntimeIndexes(root) {
1107
+ const file=join(root,'site','migrations',RUNTIME_INDEX_MIGRATION_FILE);
1108
+ if(existsSync(file))return null;
1109
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,RUNTIME_INDEX_MIGRATION);return `site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`;
1110
+ }
1111
+ function ensureAnalytics(root) {
1112
+ const file=join(root,'site','migrations',ANALYTICS_MIGRATION_FILE);
1113
+ if(existsSync(file))return null;
1114
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,ANALYTICS_MIGRATION);return `site/migrations/${ANALYTICS_MIGRATION_FILE}`;
1115
+ }
1098
1116
  export function ensureMigrations(root) {
1099
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root)].filter(Boolean);
1117
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root),ensureRuntimeIndexes(root),...Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>{const file=join(root,'site','migrations',name);if(existsSync(file))return null;mkdirSync(dirname(file),{recursive:true});writeFileSync(file,sql);return `site/migrations/${name}`;}),ensureAnalytics(root)].filter(Boolean);
1100
1118
  }
1101
1119
 
1102
1120
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shop.mjs CHANGED
@@ -50,6 +50,8 @@ export const HOOK_EVENTS = Object.freeze([
50
50
  ]);
51
51
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
52
52
  export const KEY_PERMISSIONS = Object.freeze([
53
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
54
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
53
55
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
54
56
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
55
57
  ['PaymentIntents', 'Write', 'verify, create and cancel machine payments as well as read purchase receipts'],
@@ -70,6 +72,11 @@ export const SHOP_HOOK_EVENTS = Object.freeze([
70
72
  ]);
71
73
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
72
74
  export const SHOP_KEY_PERMISSIONS = Object.freeze([
75
+ ['Subscriptions', 'Read', 'verify current subscription items and paid periods'],
76
+ ['Invoices', 'Read', 'verify subscription renewal payments'],
77
+ ['Customer portal', 'Write', 'let shop buyers manage their subscription'],
78
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
79
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
73
80
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
74
81
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
75
82
  ['PaymentIntents', 'Read', 'which payment an order was'],
@@ -0,0 +1,27 @@
1
+ /** Assets matching a protected namespace must pass through runtime authorization. */
2
+ export const WORKER_ROUTES = [
3
+ '/', '/index.html', '/api/*', '/_studio/*', '/mcp', '/mcp/*', '/oauth/*', '/hooks/*',
4
+ '/.well-known/*', '/_site/*', '/parts/*', '/games/*', '/account', '/account/*',
5
+ '/shop', '/shop/*', '/lounge', '/lounge/*', '/_homie/*', '/media/*',
6
+ '/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt',
7
+ ];
8
+ export function staticWorkerRoutes(catalogue) {
9
+ if (catalogue?.studio?.concept === true) return true;
10
+ return [...WORKER_ROUTES, ...(catalogue?.games ?? []).flatMap(game =>
11
+ [`/${game.id}`, `/${game.id}/*`, ...(game.kind === 'app' ? [] : [`!/games/${game.id}/assets/*`, `!/games/${game.id}/_landing/*`])])];
12
+ }
13
+ /** Keep the owner's header rules last. Worker responses attach their own headers. */
14
+ export function staticHeaders(custom = '', {concept = false} = {}) {
15
+ custom = custom.replace(/# BEGIN HOMIE HEADERS[\s\S]*?# END HOMIE HEADERS\n?/g, '');
16
+ return `# BEGIN HOMIE HEADERS
17
+ # Studio defaults: public static assets are served without a Worker invocation.
18
+ /*
19
+ Cache-Control: public, max-age=0, must-revalidate, no-transform
20
+ X-Frame-Options: DENY${concept ? "\n X-Robots-Tag: noindex, nofollow" : ""}
21
+ /games/*
22
+ ! X-Frame-Options
23
+ /parts/*
24
+ ! X-Frame-Options
25
+ # END HOMIE HEADERS
26
+ ${custom}`;
27
+ }
@@ -4005,6 +4005,48 @@
4005
4005
  }
4006
4006
  }
4007
4007
  }
4008
+ },
4009
+ "0.55.0": {
4010
+ "files": {
4011
+ "CLAUDE.md": "ddf2b2a0376202f6",
4012
+ "README.md": "684b3cca8001673c",
4013
+ "HANDOFF.md": "29337e2ace95b2e7",
4014
+ "games/README.md": "5ad32fe80c61f117",
4015
+ "music/README.md": "a3dc042dadd39df8",
4016
+ "videos/README.md": "16babc3437d5545b",
4017
+ "posts/README.md": "37841aae476bd540",
4018
+ "site/README.md": "1dbdf22c03a1e505",
4019
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4020
+ },
4021
+ "sections": {
4022
+ "AGENTS.md": {
4023
+ "(intro)": "394cbbe934e80722",
4024
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4025
+ "## Layout": "f3a280b8c96cd068",
4026
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4027
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4028
+ "## The site": "0cb8dd9a6f8a0096",
4029
+ "## Making games": "bb715272fd1a615b",
4030
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4031
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4032
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4033
+ "## Servers and AI seats": "1ccfd549d2369a60",
4034
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4035
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4036
+ "## Rules": "b2234d80f6cbe71d",
4037
+ "## Telling Homie": "80b46fdd9e578db8",
4038
+ "## Beta": "9d02638207a26e73"
4039
+ }
4040
+ },
4041
+ "named": {
4042
+ "Homie Arcade": {
4043
+ "sections": {
4044
+ "AGENTS.md": {
4045
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4046
+ }
4047
+ }
4048
+ }
4049
+ }
4008
4050
  }
4009
4051
  }
4010
4052
  }
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {createHash} from 'node:crypto';
2
3
  import {refreshWorkerConfig} from './worker-config.mjs';
3
4
  import {pathToFileURL} from 'node:url';
@@ -7,7 +8,7 @@ import {readConfig} from './routes.mjs';
7
8
  import {PACKAGE_ROOT,workerDir} from './studio.mjs';
8
9
  import {paidReleases} from './parts-upload.mjs';
9
10
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
10
- export async function buildWorker(root,esbuild) {
11
+ export async function buildWorker(root,esbuild,metadata=null) {
11
12
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
12
13
  const out=join(root,'site/src/runtime');
13
14
 
@@ -17,8 +18,16 @@ export async function buildWorker(root,esbuild) {
17
18
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
18
19
  // Older studio entries export only Table and Lobby. Supply the new toolkit-owned
19
20
  // classes in the generated bundle without rewriting the studio's source/customizations.
20
- const contents=`export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/gate.mjs'))};\n`;
21
- const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
21
+ const standardRooms=/export\s*\{[^}]*\bTable\b[^}]*\bLobby\b[^}]*\}\s*from\s*['"]@homie-rocks\/studio\/worker['"]/.test(readFileSync(source,'utf8'));
22
+ const lazyRooms=standardRooms?`export {Table,Lobby} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`:'';
23
+ const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
24
+ const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent',plugins:[{name:'studio-runtime',setup(build){
25
+ if(metadata)build.onLoad({filter:/[/\\]worker[/\\]built-metadata\.mjs$/},()=>({contents:'export default '+JSON.stringify(metadata),loader:'js'}));
26
+ // Merely overriding entry exports still evaluates the barrel's dependencies.
27
+ // Standard generated rooms use lazy exports at that barrel as well; a studio
28
+ // with custom subclasses retains the original class constructors.
29
+ if(standardRooms)build.onLoad({filter:/[/\\]worker[/\\]index\.mjs$/},({path})=>path===join(PACKAGE_ROOT,'worker/index.mjs')?{contents:readFileSync(path,'utf8').replace("export {Table} from './table.mjs';","export {Table} from './lazy-objects.mjs';").replace("export {Lobby} from './lobby.mjs';","export {Lobby} from './lazy-objects.mjs';").replace("export { Gate, Concentrator } from './gate.mjs';","export { Gate, Concentrator } from './lazy-objects.mjs';").replace("export {AppRecordHub, EventHub} from './stream-objects.mjs';","export {AppRecordHub, EventHub} from './lazy-objects.mjs';"),loader:'js'}:undefined);
30
+ }}]});
22
31
  // A watcher may already have read the previous entry while this build is
23
32
  // publishing. Keep its entire graph available. Publish immutable modules first,
24
33
  // then switch the single entry atomically; never remove a running graph.
@@ -41,7 +50,7 @@ export async function buildWorker(root,esbuild) {
41
50
  }
42
51
  // Wrangler's module root is the immutable generation, so old generations are
43
52
  // retained locally for in-flight reloads but are never added to a deployment.
44
- refreshWorkerConfig(root);
53
+ refreshWorkerConfig(root, metadata ? {workerRoutes: staticWorkerRoutes(metadata.catalogue)} : {});
45
54
  const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
46
55
  writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
47
56
  // Dev sometimes watches a copy with routes/remote AI removed. Publish its
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
3
  import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
3
4
  import {join} from 'node:path';
@@ -27,6 +28,7 @@ export function updateWorkerConfig(root,generated) {
27
28
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
28
29
  }
29
30
  const next=merge(base,custom);
31
+ if(readStudio(root).concept===true)next.assets={...next.assets,run_worker_first:true};
30
32
  next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
31
33
  verifiedMcpConfig(root);
32
34
  if(publicMcpSetup(root)) {
@@ -48,5 +50,5 @@ export function refreshWorkerConfig(root,overrides={}) {
48
50
  const tools=read(join(root,'site/src/tools/declarations.json'));
49
51
  const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
50
52
  const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
51
- updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
53
+ updateWorkerConfig(root,wranglerConfig({analytics:studio.stats?.analytics===true,workerRoutes:staticWorkerRoutes(read(join(root,'site/dist/games.json'))),worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
52
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
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",
@@ -0,0 +1,84 @@
1
+ /** Page counters are sampled analytics, never billing or gameplay state.
2
+ * D1 retains historical totals. Each rollup adds only the delta from the last
3
+ * observed total, in the same transaction as its watermark, so retries are safe.
4
+ */
5
+ export const PAGE_METRICS = new Set(['visit', 'play', 'screen', 'watch']);
6
+ export function writePageMetric(env, row) {
7
+ if (!env.STUDIO_ANALYTICS || !env.STUDIO_ANALYTICS_DATASET || !PAGE_METRICS.has(row.metric)) return false;
8
+ const day = row.day ?? new Date().toISOString().slice(0, 10);
9
+ try { env.STUDIO_ANALYTICS.writeDataPoint({
10
+ indexes: [String(row.subject ?? '').slice(0, 80) || 'studio'],
11
+ blobs: [day, row.metric, String(row.subject ?? '').slice(0, 80), String(row.source ?? '').slice(0, 80)],
12
+ doubles: [Math.max(0, Math.floor(Number(row.n ?? 1) || 0))],
13
+ }); } catch (error) {
14
+ // Analytics is sampled telemetry. A binding failure must not fail a page or
15
+ // amplify the outage into a D1 write on every visit.
16
+ console.warn('Studio analytics point dropped:', error.message);
17
+ }
18
+ return true;
19
+ }
20
+ async function* analyticsPages(env,{now=Date.now(),pageSize=1000}={}) {
21
+ if (!env.ANALYTICS_SQL || !env.STUDIO_ANALYTICS_DATASET) return;
22
+ const dataset=String(env.STUDIO_ANALYTICS_DATASET);
23
+ if(!/^[a-z0-9_-]+$/.test(dataset))throw new Error('Invalid studio analytics dataset');
24
+ // Revisit each UTC day for late ingestion and sample corrections. Ordered
25
+ // pages avoid the aggregate 50k result ceiling, including a busy single day.
26
+ for(let ago=7;ago>=0;ago--) {
27
+ const day=new Date(now-ago*86400000).toISOString().slice(0,10);
28
+ for(let offset=0;;offset+=pageSize) {
29
+ const result=await env.ANALYTICS_SQL.query({
30
+ query:`SELECT blob1 AS day, blob2 AS metric, blob3 AS subject, blob4 AS source, SUM(_sample_interval * double1) AS n
31
+ FROM events.analyticsEngine."${dataset}" WHERE timestamp >= $start AND blob1 = $day
32
+ GROUP BY blob1, blob2, blob3, blob4 ORDER BY blob1, blob2, blob3, blob4 LIMIT ${pageSize} OFFSET $offset`,
33
+ params:{start:day+'T00:00:00Z',day,offset},
34
+ });
35
+ if(!Array.isArray(result.data))throw new Error('Incomplete studio analytics rollup');
36
+ yield result.data.filter(r=>/^\d{4}-\d{2}-\d{2}$/.test(r.day)&&PAGE_METRICS.has(r.metric)&&Number.isFinite(Number(r.n)));
37
+ if(result.data.length<pageSize)break;
38
+ }
39
+ }
40
+ }
41
+ export async function analyticsTotals(env,now=Date.now()) {
42
+ const rows=[];for await(const page of analyticsPages(env,{now}))rows.push(...page);return rows;
43
+ }
44
+ export async function analyticsStatus(env,now=Date.now()) {
45
+ const rows=(await env.DB.prepare("SELECT key,value FROM meta WHERE key IN ('analytics-rollup-error','analytics-rollup-success')").all()).results??[];
46
+ const values=Object.fromEntries(rows.map(r=>[r.key,r.value]));
47
+ const error=values['analytics-rollup-error'];
48
+ return {lastSuccess:Number(values['analytics-rollup-success'])||null,delayed:Boolean(error)||now-Number(values['analytics-rollup-success']??0)>7200000,error:error||null};
49
+ }
50
+ async function health(env,key,value) {
51
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE value!=excluded.value').bind(key,String(value)).run();
52
+ }
53
+ export async function rollupAnalytics(env,options={}) {
54
+ if(!env.DB||!env.ANALYTICS_SQL)return;
55
+ try {
56
+ for await(const rows of analyticsPages(env,options)) {
57
+ for(let offset=0;offset<rows.length;offset+=40) {
58
+ const statements=rows.slice(offset,offset+40).flatMap(r=>{
59
+ const args=[r.day,r.metric,r.subject,r.source,Math.round(Number(r.n))];
60
+ return [
61
+ env.DB.prepare(`INSERT INTO stats_daily(day,metric,subject,source,n)
62
+ SELECT ?1,?2,?3,?4,?5-COALESCE((SELECT n FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4),0)
63
+ WHERE NOT EXISTS(SELECT 1 FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4 AND n=?5)
64
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=stats_daily.n+excluded.n`).bind(...args),
65
+ env.DB.prepare(`INSERT INTO stats_analytics_daily(day,metric,subject,source,n) VALUES(?1,?2,?3,?4,?5)
66
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=excluded.n WHERE n!=excluded.n`).bind(...args),
67
+ ];
68
+ });
69
+ if(statements.length)await env.DB.batch(statements);
70
+ }
71
+ }
72
+ await health(env,'analytics-rollup-error','');
73
+ await health(env,'analytics-rollup-success',options.now??Date.now());
74
+ return {delayed:false};
75
+ } catch(error) {
76
+ console.error('Studio analytics counts delayed:',error.message);
77
+ await health(env,'analytics-rollup-error',String(error.message).slice(0,500));
78
+ return {delayed:true,error:error.message};
79
+ }
80
+ }
81
+
82
+ export const ANALYTICS_MIGRATION_FILE="0037_owner_analytics.sql";
83
+ export const ANALYTICS_MIGRATION=`CREATE TABLE IF NOT EXISTS stats_analytics_daily(day TEXT NOT NULL,metric TEXT NOT NULL,subject TEXT NOT NULL,source TEXT NOT NULL,n INTEGER NOT NULL,PRIMARY KEY(day,metric,subject,source)) WITHOUT ROWID;
84
+ `;
@@ -1,7 +1,7 @@
1
1
  /** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
2
2
  export const APP_STORAGE_JS=String.raw`(()=>{
3
3
  for(const name of ['localStorage','sessionStorage']) {
4
- try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
4
+ try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{/* A blocked storage probe falls through to the in-memory shim. */}
5
5
  const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
6
6
  const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
7
7
  Object.defineProperty(window,name,{value:storage,configurable:true});
@@ -1,6 +1,6 @@
1
1
  export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
2
2
  if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
3
- try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
3
+ try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{/* Context creation can throw when graphics are unavailable; show the fallback. */}
4
4
  const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
5
5
  p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
6
6
  const canvas=document.querySelector('canvas');
@@ -10,11 +10,11 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
10
10
  END;`;}).join('\n')}
11
11
  `;
12
12
  const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
13
- export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
13
+ export const appHub=(env,app)=>{const ns=env.APP_RECORDS??env.LOBBY;return ns.get(ns.idFromName('@app-records/'+app));};
14
14
  const pushedRevisions=new WeakMap();
15
15
  export async function pushAppRecords(env,cat){
16
16
  const apps=(cat.games??[]).filter(g=>g.kind==='app');
17
- if(!env.LOBBY||!env.DB||!apps.length)return;
17
+ if(!(env.APP_RECORDS??env.LOBBY)||!env.DB||!apps.length)return;
18
18
  const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
19
19
  UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
20
20
  const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
@@ -32,7 +32,7 @@ export async function pushAppRecords(env,cat){
32
32
  export class AppRecordStreams {
33
33
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
34
34
  dispose(){for(const s of [...this.streams])this.close(s);}
35
- close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{}this.presence();}
35
+ close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
36
36
  count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
37
37
  send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
38
38
  presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
@@ -0,0 +1,2 @@
1
+ export const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
2
+ export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;