@homie-rocks/studio 0.53.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.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
package/lib/shop-mcp.mjs
CHANGED
|
@@ -65,11 +65,17 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
65
65
|
found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
if (!found && method==='POST' && path==='/v1/products' && params.id) {
|
|
69
|
+
const existing=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===`/v1/products/${params.id}`);
|
|
70
|
+
if (!existing) throw {next:{method:'GET',path:`/v1/products/${params.id}`,params:{expand:['default_price']}}};
|
|
71
|
+
if (existing.body?.id===params.id) return existing.body;
|
|
72
|
+
if (existing.body?.error?.code!=='resource_missing') throw new Error('Stripe product lookup failed; resolve the read before creating a product');
|
|
73
|
+
}
|
|
68
74
|
if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
|
|
69
75
|
let body=found.body;
|
|
70
76
|
if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
|
|
71
77
|
const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
|
|
72
|
-
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
|
|
78
|
+
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST' || (o.method??'GET')==='GET' && /^\/v1\/products\/[^/]+$/.test(o.path) && o.body?.id) {
|
|
73
79
|
if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
|
|
74
80
|
if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
|
|
75
81
|
}
|
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
|
+
}
|
|
@@ -3963,6 +3963,90 @@
|
|
|
3963
3963
|
}
|
|
3964
3964
|
}
|
|
3965
3965
|
}
|
|
3966
|
+
},
|
|
3967
|
+
"0.54.0": {
|
|
3968
|
+
"files": {
|
|
3969
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3970
|
+
"README.md": "684b3cca8001673c",
|
|
3971
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3972
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3973
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3974
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3975
|
+
"posts/README.md": "37841aae476bd540",
|
|
3976
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3977
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3978
|
+
},
|
|
3979
|
+
"sections": {
|
|
3980
|
+
"AGENTS.md": {
|
|
3981
|
+
"(intro)": "394cbbe934e80722",
|
|
3982
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3983
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3984
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3985
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3986
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3987
|
+
"## Making games": "bb715272fd1a615b",
|
|
3988
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3989
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3990
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3991
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3992
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3993
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3994
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3995
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3996
|
+
"## Beta": "9d02638207a26e73"
|
|
3997
|
+
}
|
|
3998
|
+
},
|
|
3999
|
+
"named": {
|
|
4000
|
+
"Homie Arcade": {
|
|
4001
|
+
"sections": {
|
|
4002
|
+
"AGENTS.md": {
|
|
4003
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
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
|
+
}
|
|
3966
4050
|
}
|
|
3967
4051
|
}
|
|
3968
4052
|
}
|
package/lib/worker-build.mjs
CHANGED
|
@@ -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
|
|
21
|
-
const
|
|
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
|
package/lib/worker-config.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/rules/view.ts
CHANGED
|
@@ -1314,7 +1314,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1314
1314
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
1315
1315
|
get status() { return solo ? 'offline' : status; },
|
|
1316
1316
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
1317
|
-
get me() { pump(); return meNow(true); },
|
|
1317
|
+
get me() { if (!presentedEntity) pump(); return meNow(true); },
|
|
1318
1318
|
each, get,
|
|
1319
1319
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
1320
1320
|
rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
|
package/shop/SHOP.md
CHANGED
|
@@ -305,7 +305,7 @@ await shop.checkout({ reference: 'table-7' });
|
|
|
305
305
|
The default opens the synced Stripe menu containing those items. The guest chooses
|
|
306
306
|
final quantities **on Stripe**, starting at one for each menu line; the local cart's
|
|
307
307
|
quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
|
|
308
|
-
removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
308
|
+
removing the last item); maximum is the item’s `maxQuantity` from shop.json, default 99 (a positive integer up to 999999). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
309
309
|
if selected items span menus, the app receives `menu` and should offer separate
|
|
310
310
|
checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
|
|
311
311
|
Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
|
|
@@ -329,8 +329,7 @@ the owner's AI write the complete manifest to `.studio/booking-slots.json` and r
|
|
|
329
329
|
|
|
330
330
|
Sync after publishing or changing slots. Removed or inactive slots deactivate their
|
|
331
331
|
links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
|
|
332
|
-
fixed service and
|
|
333
|
-
count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
332
|
+
fixed service and an adjustable group quantity up to the published remaining seats. Stripe counts completed sessions, not seats: supply the reviewed `confirmed` seat total when resyncing. The database confirms the paid quantity atomically; a capacity race becomes a visible review order rather than an overbooking. Re-sync preserves Stripe's lifetime completed count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
334
333
|
Refunds do not decrement Stripe's completed-session counter; the owner's AI must
|
|
335
334
|
review availability and resync explicitly. A changed price should use a new slot;
|
|
336
335
|
do not replace a sold link and accidentally reset its capacity counter.
|
|
@@ -441,15 +440,20 @@ stored preset or an empty amount; keep the expected total visible on the phone.
|
|
|
441
440
|
|
|
442
441
|
After a signed paid session matches the frozen link, revision, mode, currency and
|
|
443
442
|
order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
|
|
444
|
-
payment), `basketTotal` (expected total), and `settlement` (`paid`
|
|
443
|
+
payment), `basketTotal` (expected total), and `settlement` (`paid`).
|
|
445
444
|
Line `amount` is the saved extended amount (unit price × quantity); line `total`
|
|
446
445
|
is its proportionally allocated share of the actual aggregate payment for refunds.
|
|
447
|
-
A
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
repeated delivery of the same session never creates another round.
|
|
446
|
+
A payment that differs from the saved basket total emits `payment.review`, retains
|
|
447
|
+
the actual payment, and shows the customer a staff-review notice. It never emits
|
|
448
|
+
`order.paid` or grants entitlements. Staff reconcile or refund the difference.
|
|
449
|
+
Exact payments emit `order.paid`; deduplicate fulfillment by order ID.
|
|
452
450
|
|
|
453
451
|
The Stripe heading is the connected Stripe account's business name. A sandbox
|
|
454
452
|
account named “Homie sandbox” shows that name; a real pub's account shows the pub's
|
|
455
453
|
name. The basket's product name and description use the pub's own wording.
|
|
454
|
+
|
|
455
|
+
### Payments requiring review
|
|
456
|
+
|
|
457
|
+
A signed paid deposit or basket that cannot match its saved order is retained as a `review` order, with the payment ID and actual total, a customer notice and a durable `payment.review` event. Staff reconcile fulfillment or refund in Stripe. Group payments that exceed remaining capacity follow the same path without granting seats. Repeated events do not duplicate the review order.
|
|
458
|
+
|
|
459
|
+
Full connect saves `.studio/stripe-mcp.json` and public purchase configuration for the build’s JSON asset, just like plugin verification. Rebuild/deploy before retiring old links. `STRIPE_CHECKOUT_MODE` stays in public Wrangler vars; only the webhook signing secret and private purchase signing keys use secrets.
|
|
@@ -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
|
+
`;
|
package/worker/app-frame.mjs
CHANGED
|
@@ -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});
|
package/worker/app-graphics.mjs
CHANGED
|
@@ -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');
|
package/worker/app-records.mjs
CHANGED
|
@@ -4,9 +4,9 @@ import { moveBooking } from './booking-store.mjs';
|
|
|
4
4
|
import { toolIdentity } from './tool-identity.mjs';
|
|
5
5
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
6
6
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
7
|
-
import { isOwner, sameOrigin } from './office.mjs';
|
|
7
|
+
import { appAssetTicket, isOwner, sameOrigin, launchOf, settingsOf } from './office.mjs';
|
|
8
8
|
import { players } from './players.mjs';
|
|
9
|
-
import { ownerAllowed } from './stats.mjs';
|
|
9
|
+
import { cookieValues, ownerAllowed } from './stats.mjs';
|
|
10
10
|
import { appOriginOf } from './standalone.mjs';
|
|
11
11
|
export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
|
|
12
12
|
export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
|
|
@@ -52,6 +52,8 @@ export async function appAccess(request, env, meta, url) {
|
|
|
52
52
|
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
|
|
53
53
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
54
54
|
}
|
|
55
|
+
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
56
|
+
if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
|
|
55
57
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
56
58
|
if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
57
59
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
package/worker/app-shell.mjs
CHANGED
|
@@ -4,7 +4,12 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
4
4
|
if (!boot || boot.kind !== 'app') return;
|
|
5
5
|
var frame = boot.direct ? {contentWindow:window} : document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
6
|
var params = new URLSearchParams(location.search);
|
|
7
|
-
//
|
|
7
|
+
// Authorized shell responses have already set the HttpOnly role handoff.
|
|
8
|
+
if(params.has('access')) {
|
|
9
|
+
params.delete('access');
|
|
10
|
+
history.replaceState(null,'',location.pathname+(params.size?'?'+params:'')+(location.hash||''));
|
|
11
|
+
}
|
|
12
|
+
// Share short-lived reads across same-origin screens with the same role and browser cookies.
|
|
8
13
|
// No records are persisted. Writes always reach the Worker and invalidate all views.
|
|
9
14
|
var recent = new Map(), failures = new Map(), pending = new Map();
|
|
10
15
|
var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
|
|
@@ -38,11 +43,11 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
38
43
|
var live, collections=new Set(), presence=0, clock=null;
|
|
39
44
|
function query() {
|
|
40
45
|
var q=new URLSearchParams();
|
|
41
|
-
['role','surface','
|
|
46
|
+
['role','surface','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
|
|
42
47
|
if(boot.screen&&!q.has('role'))q.set('surface','wall');
|
|
43
48
|
return q;
|
|
44
49
|
}
|
|
45
|
-
function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
|
|
50
|
+
function context() {var room=new URLSearchParams(location.search).get('room');return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open'+(room&&/^[A-Za-z0-9_-]{1,32}$/.test(room)?'?room='+encodeURIComponent(room):''),presence:presence,now:clock};}
|
|
46
51
|
function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
|
|
47
52
|
function connect(){
|
|
48
53
|
live?.close();
|
|
@@ -63,7 +68,7 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
63
68
|
if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
64
69
|
if (m.type==='homie-app' && (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record)))) return;
|
|
65
70
|
var q = new URLSearchParams();
|
|
66
|
-
['role','surface'
|
|
71
|
+
['role','surface'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
67
72
|
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
68
73
|
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
69
74
|
if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
|
package/worker/app-streams.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding} from './request-db.mjs';
|
|
1
2
|
import {appAccess} from './app-records.mjs';
|
|
2
3
|
import {localSession} from './local-test.mjs';
|
|
3
4
|
import {settingsOf,launchOf,accessOf} from './office.mjs';
|
|
@@ -9,16 +10,29 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
|
|
|
9
10
|
END;`;}).join('\n')}
|
|
10
11
|
`;
|
|
11
12
|
const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
|
|
12
|
-
export const appHub=(env,app)=>env.LOBBY.get(
|
|
13
|
+
export const appHub=(env,app)=>{const ns=env.APP_RECORDS??env.LOBBY;return ns.get(ns.idFromName('@app-records/'+app));};
|
|
14
|
+
const pushedRevisions=new WeakMap();
|
|
13
15
|
export async function pushAppRecords(env,cat){
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const apps=(cat.games??[]).filter(g=>g.kind==='app');
|
|
17
|
+
if(!(env.APP_RECORDS??env.LOBBY)||!env.DB||!apps.length)return;
|
|
18
|
+
const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
|
|
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
|
+
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));}
|
|
21
|
+
const binding=databaseBinding(env.DB),previous=pushedRevisions.get(binding)??new Map();
|
|
22
|
+
for(const meta of apps){
|
|
23
|
+
const signature=signatures.get(meta.id)??'';
|
|
24
|
+
if((previous.get(meta.id)??'')===signature)continue;
|
|
25
|
+
const response=await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})});
|
|
26
|
+
if(!response.ok)throw new Error('App notification wake failed');
|
|
27
|
+
previous.set(meta.id,signature);
|
|
28
|
+
}
|
|
29
|
+
pushedRevisions.set(binding,previous);
|
|
16
30
|
}
|
|
17
31
|
/** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
|
|
18
32
|
export class AppRecordStreams {
|
|
19
33
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
20
34
|
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
21
|
-
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);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();}
|
|
22
36
|
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
23
37
|
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
24
38
|
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
@@ -28,7 +42,7 @@ export class AppRecordStreams {
|
|
|
28
42
|
const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
|
|
29
43
|
return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
|
|
30
44
|
}
|
|
31
|
-
async revisions(s){return (await this.env.DB.prepare(
|
|
45
|
+
async revisions(s){return (await this.env.DB.prepare("SELECT collection,revision FROM app_record_revisions WHERE app=?1 UNION ALL SELECT '*',COALESCE(SUM(r.revision),0) FROM app_tool_namespaces a JOIN tool_data_revisions r ON r.namespace=a.namespace WHERE a.app=?1").bind(s.app).all()).results;}
|
|
32
46
|
async open({meta,url,headers,collections=[]}){
|
|
33
47
|
if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
|
|
34
48
|
const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
|
|
@@ -40,17 +54,20 @@ export class AppRecordStreams {
|
|
|
40
54
|
const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
|
|
41
55
|
this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
|
|
42
56
|
for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
|
|
57
|
+
// A fresh HTTP response renews the role cookie; SSE frames cannot set cookies.
|
|
58
|
+
s.reconnect=setTimeout(()=>this.close(s),240000);
|
|
43
59
|
// Recovery after a missed wake; this is not the screen's refresh clock.
|
|
44
|
-
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
|
|
60
|
+
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s,true)),15000);
|
|
45
61
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
46
62
|
}
|
|
47
|
-
async pump(s){
|
|
63
|
+
async pump(s,recheck=false){
|
|
48
64
|
if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
|
|
49
65
|
try{
|
|
66
|
+
const changed=new Set();
|
|
67
|
+
for(const row of await this.revisions(s)){if(s.seen.get(row.collection)!==row.revision){if(row.collection==='*')for(const c of s.collections)changed.add(c);else if(s.collections.includes(row.collection))changed.add(row.collection);}s.seen.set(row.collection,row.revision);}
|
|
68
|
+
if(!changed.size&&!recheck)return;
|
|
50
69
|
if(!await this.authorized(s)){this.close(s);return;}
|
|
51
|
-
|
|
52
|
-
for(const row of await this.revisions(s)){if(s.collections.includes(row.collection)&&s.seen.get(row.collection)!==row.revision)changed.push(row.collection);s.seen.set(row.collection,row.revision);}
|
|
53
|
-
this.send(s,{collections:changed,presence:this.count(s)});
|
|
70
|
+
this.send(s,{collections:[...changed],presence:this.count(s)});
|
|
54
71
|
}catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
|
|
55
72
|
}
|
|
56
73
|
async wake(meta){for(const s of this.streams)if(meta)s.meta=meta;await Promise.all([...this.streams].map(s=>this.pump(s)));return new Response('ok');}
|
package/worker/app-tools.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export async function appToolsRoute(request,env,meta,url,cat,definitions){
|
|
|
37
37
|
if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
|
|
38
38
|
if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
|
|
39
39
|
const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
|
|
40
|
+
await env.DB.prepare('INSERT OR IGNORE INTO app_tool_namespaces(app,namespace) VALUES(?1,?2)').bind(meta.id+(caller.testScope?':check:'+caller.testScope:''),namespace).run();
|
|
40
41
|
const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
|
|
41
42
|
outcome='ok';return Response.json({ok:true,data});
|
|
42
43
|
}catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {catalogue} from './catalogue.mjs';
|
|
2
|
+
/** Durable rows remain the source of truth. Messages carry wakeups, never records. */
|
|
3
|
+
export async function enqueueChanges(env, origin) {
|
|
4
|
+
if(env.BACKGROUND) {
|
|
5
|
+
try {return await env.BACKGROUND.send({v:1,type:'studio.changed',origin});}
|
|
6
|
+
catch(error) {console.warn('Background queue unavailable; delivering inline:',error.message);}
|
|
7
|
+
}
|
|
8
|
+
// Older wrappers and local unit fixtures without the binding keep their behavior.
|
|
9
|
+
return (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
|
|
10
|
+
}
|
|
11
|
+
export async function consumeBackground(batch, env) {
|
|
12
|
+
const changes=batch.messages.filter(m=>m.body?.v===1&&m.body.type==='studio.changed');
|
|
13
|
+
if(changes.length) {
|
|
14
|
+
try {
|
|
15
|
+
const origin=changes[0].body.origin;
|
|
16
|
+
await (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
|
|
17
|
+
for(const message of changes)message.ack();
|
|
18
|
+
} catch(error) {
|
|
19
|
+
console.error('Background delivery failed',error);
|
|
20
|
+
for(const message of changes)message.retry({delaySeconds:30});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for(const message of batch.messages)if(!changes.includes(message)) {
|
|
24
|
+
try {
|
|
25
|
+
if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
|
|
26
|
+
await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
|
|
27
|
+
message.ack();
|
|
28
|
+
} catch(error) {console.error('Background queue delivery failed',error);message.retry({delaySeconds:error.retryAfterSeconds??30});}
|
|
29
|
+
}
|
|
30
|
+
}
|
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.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.55.0","sha256":"597d65592bde7e9519b8184bc2db2502a88ef37cfc31dd6ba3b1c9a4ece30132"});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const cache = new WeakMap();
|
|
2
|
+
/** Cache completed immutable data only. Never share pending I/O across requests. */
|
|
3
|
+
export async function catalogue(env, origin = 'https://studio.invalid') {
|
|
4
|
+
const baked = (await import('./built-metadata.mjs')).default;
|
|
5
|
+
if (baked) return baked.catalogue;
|
|
6
|
+
const hit = cache.get(env.ASSETS);
|
|
7
|
+
if (hit?.origin === origin && env.HOMIE_PREVIEW !== '1' && env.HOMIE_DEV !== '1') return hit.value;
|
|
8
|
+
const response = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
|
|
9
|
+
if (!response.ok) return {studio: {name: env.STUDIO_NAME || 'Studio'}, games: []};
|
|
10
|
+
const tag=response.headers.get('etag');
|
|
11
|
+
if(tag&&hit?.origin===origin&&hit.tag===tag){await response.body?.cancel();return hit.value;}
|
|
12
|
+
const value = await response.json();
|
|
13
|
+
cache.set(env.ASSETS, {origin, value, tag});
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export async function builtPosts() {
|
|
17
|
+
return (await import('./built-metadata.mjs')).default?.posts;
|
|
18
|
+
}
|