@homie-rocks/studio 0.53.0 → 0.54.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 +30 -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/build.mjs +1 -1
- package/lib/check.mjs +10 -2
- package/lib/dev.mjs +8 -1
- package/lib/scaffold.mjs +20 -1
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +42 -25
- package/worker/office.mjs +89 -14
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +62 -7
- package/worker/version.mjs +1 -1
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';
|
|
@@ -10,15 +11,28 @@ END;`;}).join('\n')}
|
|
|
10
11
|
`;
|
|
11
12
|
const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
|
|
12
13
|
export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.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.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{}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');}
|
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.54.0","sha256":"4f65918ccb0f52120d8d0fa6cf761f2243b4636fdf10ccef395479318d603370"});
|
package/worker/chat-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
|
|
3
4
|
* and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
|
|
@@ -51,8 +52,8 @@ const cache = new WeakMap();
|
|
|
51
52
|
/** Every chat rule row (D1), read at most every 5 s per Worker instance; empty before migration 0007. */
|
|
52
53
|
export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
53
54
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
54
|
-
const hit = cache.get(env.DB);
|
|
55
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
55
|
+
const hit = cache.get(databaseBinding(env.DB));
|
|
56
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
56
57
|
let map = new Map();
|
|
57
58
|
try {
|
|
58
59
|
const { results } = await env.DB.prepare('SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000').all();
|
|
@@ -62,10 +63,10 @@ export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
|
62
63
|
if (rules && typeof rules === 'object') map.set(`${r.game}/${r.server ?? ''}`, { ...rules, at: Number(r.updated_at) || 0 });
|
|
63
64
|
}
|
|
64
65
|
} catch { map = new Map(); }
|
|
65
|
-
cache.set(env.DB, { at: Date.now(), map });
|
|
66
|
+
cache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
66
67
|
return map;
|
|
67
68
|
}
|
|
68
|
-
export const forgetChat = (env) => { if (env?.DB) cache.delete(env.DB); };
|
|
69
|
+
export const forgetChat = (env) => { if (env?.DB) cache.delete(databaseBinding(env.DB)); };
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* A room's chat rules: the game's game.json "chat", the owner's rules for the game, then for this server (a server's
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding} from './request-db.mjs';
|
|
1
2
|
import {McpEventStreams} from './mcp-event-streams.mjs';
|
|
2
3
|
import {listEvents} from './mcp-events.mjs';
|
|
3
4
|
import {notification,sseFrame} from './mcp-events-wire.mjs';
|
|
@@ -141,13 +142,16 @@ export class EventStreams {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
const toolCatalogues=new WeakMap();
|
|
144
146
|
/** Refresh active streams whenever this Worker observes a new tool catalogue. */
|
|
145
147
|
export async function pushToolList(env,cat,tools,origin) {
|
|
146
148
|
if(!env.LOBBY)return;
|
|
147
149
|
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
148
150
|
const signature=JSON.stringify([publicShape,eventFeeds(env)]);
|
|
151
|
+
const binding=databaseBinding(env.DB);
|
|
152
|
+
if(toolCatalogues.get(binding)===signature)return;
|
|
149
153
|
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
150
|
-
if(previous?.value===signature)return;
|
|
154
|
+
if(previous?.value===signature){toolCatalogues.set(binding,signature);return;}
|
|
151
155
|
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
152
156
|
let after='';
|
|
153
157
|
for(;;){
|
|
@@ -155,4 +159,5 @@ export async function pushToolList(env,cat,tools,origin) {
|
|
|
155
159
|
for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
|
|
156
160
|
if(rows.length<100)break;after=rows.at(-1).key;
|
|
157
161
|
}
|
|
162
|
+
toolCatalogues.set(binding,signature);
|
|
158
163
|
}
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {trackBackground,requestDatabase,databaseBinding,MUTATIONS} from './request-db.mjs';
|
|
1
2
|
import {appGraphicsFallback} from './app-graphics.mjs';
|
|
2
3
|
import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
|
|
3
4
|
import {loadShopLinks} from './shop-links.mjs';
|
|
@@ -123,7 +124,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
|
|
|
123
124
|
import { ownerRoutes } from './stats-page.mjs';
|
|
124
125
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
125
126
|
import {
|
|
126
|
-
appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
127
|
+
appRoleSession, appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
127
128
|
ticketFor, ticketSub, usePlayers, verifyControl,
|
|
128
129
|
} from './office.mjs';
|
|
129
130
|
|
|
@@ -168,13 +169,15 @@ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(
|
|
|
168
169
|
const catalogueCache = new WeakMap();
|
|
169
170
|
async function catalogue(env, origin) {
|
|
170
171
|
try {
|
|
172
|
+
const cached=catalogueCache.get(env.ASSETS);
|
|
173
|
+
if(cached?.origin===origin && env.HOMIE_PREVIEW!=='1' && env.HOMIE_DEV!=='1')return cached.value;
|
|
171
174
|
const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
|
|
172
175
|
if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
|
|
173
176
|
const tag = res.headers.get('etag');
|
|
174
177
|
const hit = catalogueCache.get(env.ASSETS);
|
|
175
178
|
if (tag && hit?.tag === tag && hit.origin === origin) { await res.body?.cancel(); return hit.value; }
|
|
176
179
|
const value = await res.json();
|
|
177
|
-
|
|
180
|
+
catalogueCache.set(env.ASSETS, { tag, origin, value });
|
|
178
181
|
return value;
|
|
179
182
|
} catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
|
|
180
183
|
}
|
|
@@ -539,7 +542,13 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
539
542
|
};
|
|
540
543
|
let html = await res.text();
|
|
541
544
|
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
|
|
542
|
-
|
|
545
|
+
let assetBase='';
|
|
546
|
+
const assetLaunch=meta.kind==='app'?launchOf(meta,await settingsOf(env,{fresh:true}),env):'public';
|
|
547
|
+
if(meta.kind==='app'&&!url.pathname.includes('/__assets/')&&assetLaunch!=='public') {
|
|
548
|
+
const ticket=await appAssetTicket(env,game,undefined,{request,meta,launch:assetLaunch});
|
|
549
|
+
if(!ticket)return new Response('Sign in again to load this private app.',{status:403,headers:{'cache-control':'no-store'}});
|
|
550
|
+
assetBase=`<base href="/${game}/__assets/${ticket}/">`;
|
|
551
|
+
}
|
|
543
552
|
const head = assetBase + `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
544
553
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
545
554
|
return new Response(html, {
|
|
@@ -616,16 +625,10 @@ function finish(res, path) {
|
|
|
616
625
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
617
626
|
}
|
|
618
627
|
|
|
619
|
-
// Repeat after migration: an older isolate may recreate these during a rolling deploy.
|
|
620
|
-
async function removeReferralLeftovers(env) {
|
|
621
|
-
if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
|
|
622
|
-
}
|
|
623
|
-
|
|
624
628
|
export default {
|
|
625
629
|
async scheduled(event, env, ctx) {
|
|
626
630
|
env=await loadShopLinks(env);
|
|
627
631
|
const maintenance=(async () => {
|
|
628
|
-
await removeReferralLeftovers(env);
|
|
629
632
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
630
633
|
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
631
634
|
await reconcileOrders(env, shopOf(cat));
|
|
@@ -635,20 +638,19 @@ export default {
|
|
|
635
638
|
await maintenance;
|
|
636
639
|
},
|
|
637
640
|
async fetch(request, env, ctx) {
|
|
641
|
+
const staticPath=new URL(request.url).pathname;
|
|
642
|
+
if(['GET','HEAD'].includes(request.method)&&staticPath==='/_homie/site.js')return finish(await route(request,env,ctx),staticPath);
|
|
638
643
|
env=await loadShopLinks(env,new URL(request.url).origin);
|
|
639
|
-
|
|
644
|
+
env={...env,DB:requestDatabase(env.DB,request)};
|
|
640
645
|
const url = new URL(request.url);
|
|
641
646
|
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
|
|
642
647
|
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
|
|
643
|
-
await (await import('./functions.mjs')).syncFunctions(env);
|
|
644
648
|
const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
|
|
645
649
|
// Some routes reconcile shop state in waitUntil after returning their page.
|
|
646
650
|
// Keep dispatch behind those writes too, preserving the runtime context's
|
|
647
651
|
// methods and OAuth verification symbols through the proxy.
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
|
|
651
|
-
}}):ctx;
|
|
652
|
+
const background=trackBackground(ctx,()=>env.DB?.[MUTATIONS]?dispatch():undefined);
|
|
653
|
+
const requestCtx=background.context;
|
|
652
654
|
// A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
|
|
653
655
|
// unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
|
|
654
656
|
const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
|
|
@@ -661,10 +663,16 @@ export default {
|
|
|
661
663
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
662
664
|
} finally {
|
|
663
665
|
// Refresh after the route has populated the catalogue cache.
|
|
664
|
-
if(env.DB&&env.LOBBY&&ctx?.waitUntil)
|
|
666
|
+
if(env.DB&&env.LOBBY&&ctx?.waitUntil) {
|
|
667
|
+
const binding=databaseBinding(env.DB),mutation=url.pathname.startsWith('/_studio/')&&env.DB[MUTATIONS];
|
|
668
|
+
if(!toolCatalogues.has(binding)||mutation) {
|
|
669
|
+
toolCatalogues.add(binding);
|
|
670
|
+
ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>toolCatalogues.delete(binding)));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
665
673
|
// A route can commit a write and then fail. Its outbox still needs delivery.
|
|
666
674
|
if(env.DB) {
|
|
667
|
-
const delivery=
|
|
675
|
+
const delivery=background.finish();
|
|
668
676
|
if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
|
|
669
677
|
}
|
|
670
678
|
}
|
|
@@ -683,6 +691,8 @@ export default {
|
|
|
683
691
|
},
|
|
684
692
|
};
|
|
685
693
|
|
|
694
|
+
// Refresh once per isolate/binding, and again after studio mutations. No shared pending I/O.
|
|
695
|
+
const toolCatalogues=new WeakSet();
|
|
686
696
|
async function notifyTools(env,cat,origin) {
|
|
687
697
|
if(!env.DB||!env.LOBBY)return;
|
|
688
698
|
const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
|
|
@@ -950,8 +960,11 @@ async function route(request, env, ctx) {
|
|
|
950
960
|
const meta = (all.games ?? []).find((g) => g.id === game);
|
|
951
961
|
if (!meta) return fallback(request, env, url, getCat);
|
|
952
962
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
953
|
-
const cat = await getCat();
|
|
954
963
|
let sub = parts.slice(1).join('/');
|
|
964
|
+
// Game bundles were already public assets; only the document carries access context.
|
|
965
|
+
// Apps retain their session/role checks even for individual asset files.
|
|
966
|
+
if(read&&meta.kind!=='app'&&sub.startsWith('__game/')&&!['__game/','__game/index.html'].includes(sub))return gameAsset(request,env,url,game,sub.slice('__game/'.length));
|
|
967
|
+
const cat = await getCat();
|
|
955
968
|
if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
|
|
956
969
|
if (sub === '__restart') {
|
|
957
970
|
if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
|
|
@@ -968,7 +981,7 @@ async function route(request, env, ctx) {
|
|
|
968
981
|
if (meta.kind === 'app') {
|
|
969
982
|
const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
|
|
970
983
|
if(asset) {
|
|
971
|
-
if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
|
|
984
|
+
if(!(await appAssetTicket(env,game,asset[1],{request,meta,launch})))return json({ok:false,error:'not-found'},404);
|
|
972
985
|
const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
|
|
973
986
|
response.headers.set('cache-control','private, no-store');
|
|
974
987
|
response.headers.set('referrer-policy','no-referrer');
|
|
@@ -980,14 +993,18 @@ async function route(request, env, ctx) {
|
|
|
980
993
|
const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
|
|
981
994
|
if(web)return web;
|
|
982
995
|
}
|
|
996
|
+
const apiSession=async response=>{
|
|
997
|
+
const access=await appAccess(request,env,meta,url);
|
|
998
|
+
return access.ok?appRoleSession(response,request,env,meta,launch):response;
|
|
999
|
+
};
|
|
983
1000
|
if(sub==='api/app/live') {
|
|
984
1001
|
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
985
1002
|
const {appHub}=await import('./app-streams.mjs');
|
|
986
|
-
return appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})});
|
|
1003
|
+
return apiSession(await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})}));
|
|
987
1004
|
}
|
|
988
|
-
if(sub==='api/app/tools')return (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools());
|
|
1005
|
+
if(sub==='api/app/tools')return apiSession(await (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools()));
|
|
989
1006
|
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
990
|
-
if (api) return api;
|
|
1007
|
+
if (api) return sub.startsWith('api/app/records/')?apiSession(api):api;
|
|
991
1008
|
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
992
1009
|
let a;
|
|
993
1010
|
try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
|
|
@@ -995,7 +1012,7 @@ async function route(request, env, ctx) {
|
|
|
995
1012
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
996
1013
|
return json({ ok: false, error: a.error }, a.status);
|
|
997
1014
|
}
|
|
998
|
-
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game):null);
|
|
1015
|
+
if(!meta.room)return appRoleSession(recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game,undefined,{request,meta,launch}):null),request,env,meta,launch);
|
|
999
1016
|
}
|
|
1000
1017
|
}
|
|
1001
1018
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -1140,11 +1157,11 @@ async function route(request, env, ctx) {
|
|
|
1140
1157
|
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
1141
1158
|
let shopQr = null;
|
|
1142
1159
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
1143
|
-
return playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
1160
|
+
return appRoleSession(playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null }),request,env,meta,launch);
|
|
1144
1161
|
}
|
|
1145
1162
|
await countVisit(request, env, ctx, game, 'play');
|
|
1146
1163
|
shareDaily(cat, url, ctx);
|
|
1147
|
-
const page=playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1164
|
+
const page=await appRoleSession(playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) }),request,env,meta,launch);
|
|
1148
1165
|
for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
|
|
1149
1166
|
}
|
|
1150
1167
|
if (sub === 'watch') {
|
package/worker/office.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
import { selling } from './extensions.mjs';
|
|
2
3
|
import { purchaseOrderIfPresent } from './shop.mjs';
|
|
3
4
|
import { toolIdentity } from './tool-identity.mjs';
|
|
@@ -170,6 +171,7 @@ export async function accountSub(request, env) {
|
|
|
170
171
|
|
|
171
172
|
/** The owner: the signed-in owner's browser session, or (with player accounts) the owner's own account. */
|
|
172
173
|
export async function isOwner(request, env) {
|
|
174
|
+
if(toolIdentity(request)?.owner)return true;
|
|
173
175
|
if ((await ownerAllowed(request, env, { kinds: ['session'] })) === 'session') return true;
|
|
174
176
|
try { return players?.isOwner ? (await players.isOwner(request, env)) === true : false; } catch { return false; }
|
|
175
177
|
}
|
|
@@ -181,17 +183,17 @@ const settingsCache = new WeakMap();
|
|
|
181
183
|
/** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0005. */
|
|
182
184
|
export async function settingsOf(env, { fresh = false } = {}) {
|
|
183
185
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
184
|
-
const hit = settingsCache.get(env.DB);
|
|
185
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
186
|
+
const hit = settingsCache.get(databaseBinding(env.DB));
|
|
187
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
186
188
|
let map = new Map();
|
|
187
189
|
try {
|
|
188
190
|
const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
|
|
189
191
|
map = new Map((results ?? []).map((r) => [r.game, r]));
|
|
190
192
|
} catch { map = new Map(); }
|
|
191
|
-
settingsCache.set(env.DB, { at: Date.now(), map });
|
|
193
|
+
settingsCache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
192
194
|
return map;
|
|
193
195
|
}
|
|
194
|
-
const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(env.DB); };
|
|
196
|
+
const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(databaseBinding(env.DB)); };
|
|
195
197
|
|
|
196
198
|
/** A game's launch state: the owner's (D1), else its game.json `launch`, else public. A Preview enforces none. */
|
|
197
199
|
export function launchOf(meta, settings, env) {
|
|
@@ -221,7 +223,7 @@ function hexBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i
|
|
|
221
223
|
/** The studio's office secret (a random 32 bytes in D1 meta, made on first use), as an HMAC key; null without D1. */
|
|
222
224
|
async function officeKey(env) {
|
|
223
225
|
if (!env?.DB) return null;
|
|
224
|
-
if (keys.has(env.DB)) return keys.get(env.DB);
|
|
226
|
+
if (keys.has(databaseBinding(env.DB))) return keys.get(databaseBinding(env.DB));
|
|
225
227
|
let hex = null;
|
|
226
228
|
try {
|
|
227
229
|
hex = (await env.DB.prepare("SELECT value FROM meta WHERE key = 'office_key'").first())?.value ?? null;
|
|
@@ -232,7 +234,7 @@ async function officeKey(env) {
|
|
|
232
234
|
} catch { hex = null; }
|
|
233
235
|
if (!/^[a-f0-9]{64}$/.test(String(hex))) return null;
|
|
234
236
|
const key = await crypto.subtle.importKey('raw', hexBytes(hex), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
235
|
-
keys.set(env.DB, key);
|
|
237
|
+
keys.set(databaseBinding(env.DB), key);
|
|
236
238
|
return key;
|
|
237
239
|
}
|
|
238
240
|
async function mac(key, text) { return b64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))); }
|
|
@@ -330,6 +332,8 @@ async function inviteOpen(env, id) {
|
|
|
330
332
|
*/
|
|
331
333
|
export async function accessOf(request, env, game, launch) {
|
|
332
334
|
if (launch === 'public') return { ok: true, sub: null, owner: false };
|
|
335
|
+
const caller=toolIdentity(request);
|
|
336
|
+
if(caller?.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.testApp===game)return {ok:true,sub:null,owner:false};
|
|
333
337
|
if (await isOwner(request, env)) return { ok: true, sub: 'o', owner: true };
|
|
334
338
|
if (launch === 'invite') {
|
|
335
339
|
const pass = await passOf(request, env, game);
|
|
@@ -1378,15 +1382,70 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1378
1382
|
return null;
|
|
1379
1383
|
}
|
|
1380
1384
|
|
|
1381
|
-
/**
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1385
|
+
/** Encrypted, HMAC-authenticated asset capability. Session hashes never enter URLs in plaintext.
|
|
1386
|
+
* Every use checks revocation, grants and role-link rotation in one D1 statement.
|
|
1387
|
+
*/
|
|
1388
|
+
export async function appAssetTicket(env, app, ticket, {request,meta,launch=meta?.launch??'public',expectedRole,purpose='asset',renew=false}={}) {
|
|
1389
|
+
const key=await officeKey(env),issuing=ticket===undefined,denied=issuing?null:false;
|
|
1390
|
+
if(!key||!meta)return denied;
|
|
1391
|
+
const decode=text=>Uint8Array.from(atob(text.replaceAll('-','+').replaceAll('_','/')),c=>c.charCodeAt(0));
|
|
1392
|
+
const cipher=await crypto.subtle.importKey('raw',decode(await mac(key,'app-assets-encryption-v1')),'AES-GCM',false,['encrypt','decrypt']);
|
|
1393
|
+
let data;
|
|
1394
|
+
if(issuing) {
|
|
1395
|
+
if(!request)return null;
|
|
1396
|
+
const url=new URL(request.url),{appRole}=await import('./app-format.mjs');
|
|
1397
|
+
const role=appRole(meta,url),rights=meta.roles?.[role];if(!rights)return null;
|
|
1398
|
+
const owner=cookieValues(request,OWNER_COOKIE)[0],player=cookieValues(request,'studio_player')[0],pass=cookieValues(request,passCookie(app))[0],caller=toolIdentity(request);
|
|
1399
|
+
data={purpose,exp:Date.now()+5*60_000,role,rights:await sha256(JSON.stringify(rights)),hash:null,pass:pass?await sha256(pass):null,connection:null,person:null};
|
|
1400
|
+
if(owner||player){data.kind=owner?'owner':'player';data.hash=await sha256(owner??player);}
|
|
1401
|
+
else if(caller){
|
|
1402
|
+
if(caller.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.roles?.[app]?.includes(role)){data.kind='test';data.testApp=caller.testApp??app;}
|
|
1403
|
+
else {data.kind='tool';data.connection=caller.connection??null;data.person=caller.id??null;if(data.person?.startsWith('office-'))data.hash=data.person.slice(7);}
|
|
1404
|
+
} else if(launch==='invite'&&!rights.signIn&&pass){data.kind='pass';}
|
|
1405
|
+
else {
|
|
1406
|
+
const session=await (await import('./local-test.mjs')).localSession(request,env);
|
|
1407
|
+
if(session?.app!==app||session.role!==role)return null;
|
|
1408
|
+
data.kind='test';data.testApp=app;data.exp=Math.min(data.exp,session.expires??data.exp);
|
|
1409
|
+
}
|
|
1410
|
+
} else {
|
|
1411
|
+
try {
|
|
1412
|
+
const [iv,payload,signature,...extra]=ticket.split('.');
|
|
1413
|
+
if(extra.length||!signature||!same(await mac(key,`app-assets|${app}|${iv}|${payload}`),signature))return false;
|
|
1414
|
+
data=JSON.parse(new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv:decode(iv)},cipher,decode(payload))));
|
|
1415
|
+
}catch{return false;}
|
|
1416
|
+
}
|
|
1417
|
+
if(data.kind==='test') {
|
|
1418
|
+
const session=request&&await (await import('./local-test.mjs')).localSession(request,env);
|
|
1419
|
+
if(!issuing&&(!session||session.app!==app||session.role!==data.role||(session.scope??null)!==data.testScope))return denied;
|
|
1420
|
+
if(issuing)data.testScope=session?.scope??toolIdentity(request)?.testScope??null;
|
|
1387
1421
|
}
|
|
1388
|
-
|
|
1389
|
-
|
|
1422
|
+
if(data.purpose!==purpose||(purpose==='handoff'&&!['owner','player'].includes(data.kind)))return denied;
|
|
1423
|
+
if(expectedRole&&data.role!==expectedRole)return denied;
|
|
1424
|
+
if(!Number.isSafeInteger(data.exp)||data.exp<=Date.now()||data.exp>Date.now()+5*60_000||!meta.roles?.[data.role]||data.rights!==await sha256(JSON.stringify(meta.roles[data.role])))return denied;
|
|
1425
|
+
const state=await env.DB.prepare(`SELECT
|
|
1426
|
+
(SELECT token FROM app_role_links WHERE app=?1 AND role=?2) link,
|
|
1427
|
+
EXISTS(SELECT 1 FROM stats_keys WHERE hash=?3 AND kind='session' AND expires_at>?4) owner,
|
|
1428
|
+
EXISTS(SELECT 1 FROM office_passes p JOIN office_invites i ON i.id=p.invite WHERE p.hash=?5 AND p.game=?1 AND p.expires_at>?4 AND i.revoked=0) pass,
|
|
1429
|
+
(SELECT p.id FROM players p WHERE p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4)) player,
|
|
1430
|
+
EXISTS(SELECT 1 FROM players p WHERE p.owner=1 AND (p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4) OR p.id=?6)) playerOwner,
|
|
1431
|
+
EXISTS(SELECT 1 FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player IN (?6,(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4))) granted,
|
|
1432
|
+
EXISTS(SELECT 1 FROM mcp_connections WHERE id=?7 AND person=?6 AND revoked=0) connected
|
|
1433
|
+
`).bind(app,data.role,data.hash??null,Date.now(),data.pass??null,data.person??null,data.connection??null).first();
|
|
1434
|
+
if(issuing&&purpose==='asset'&&launch==='invite'&&!meta.roles[data.role].signIn&&state.pass)data.kind='pass';
|
|
1435
|
+
const gate=state.owner||state.playerOwner||launch==='public'||launch==='invite'&&state.pass;
|
|
1436
|
+
const roleAllowed=!meta.roles[data.role].signIn||state.owner||state.playerOwner||state.granted;
|
|
1437
|
+
const authorized=data.kind==='owner'?state.owner:data.kind==='pass'?launch==='invite'&&!meta.roles[data.role].signIn&&state.pass:data.kind==='player'?state.player&&gate&&roleAllowed:data.kind==='tool'?state.connected&&gate&&roleAllowed:data.kind==='test'?data.testApp===app:false;
|
|
1438
|
+
if(!authorized)return denied;
|
|
1439
|
+
const link=state.link?await sha256(state.link):null;
|
|
1440
|
+
if(!issuing) {
|
|
1441
|
+
if(data.link!==link)return denied;
|
|
1442
|
+
if(!renew||purpose!=='handoff')return true;
|
|
1443
|
+
data.exp=Date.now()+5*60_000;
|
|
1444
|
+
}
|
|
1445
|
+
data.link=link;
|
|
1446
|
+
const iv=crypto.getRandomValues(new Uint8Array(12)),ivText=b64url(iv);
|
|
1447
|
+
const payload=b64url(new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv},cipher,new TextEncoder().encode(JSON.stringify(data)))));
|
|
1448
|
+
return `${ivText}.${payload}.${await mac(key,`app-assets|${app}|${ivText}|${payload}`)}`;
|
|
1390
1449
|
}
|
|
1391
1450
|
|
|
1392
1451
|
/** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
|
|
@@ -1404,3 +1463,19 @@ export async function checkSessionTicket(env, data, token) {
|
|
|
1404
1463
|
return session;
|
|
1405
1464
|
}catch{return null;}
|
|
1406
1465
|
}
|
|
1466
|
+
|
|
1467
|
+
/** Same-origin role handoff for opening another screen, never the long-lived role URL secret. */
|
|
1468
|
+
export async function appRoleSession(response,request,env,meta,launch) {
|
|
1469
|
+
if(meta.kind!=='app')return response;
|
|
1470
|
+
const {appRole}=await import('./app-format.mjs'),role=appRole(meta,new URL(request.url));
|
|
1471
|
+
if(!meta.roles?.[role]?.signIn)return response;
|
|
1472
|
+
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
1473
|
+
const ticket=(handoff&&await appAssetTicket(env,meta.id,handoff,{request,meta,launch,purpose:'handoff',expectedRole:role,renew:true}))||await appAssetTicket(env,meta.id,undefined,{request,meta,launch,purpose:'handoff'});
|
|
1474
|
+
if(ticket) {
|
|
1475
|
+
response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
|
|
1476
|
+
// LAN dev uses HTTP: a Secure cookie is discarded before the shell removes access.
|
|
1477
|
+
const secure=new URL(request.url).protocol==='https:'?'; Secure':'';
|
|
1478
|
+
response.headers.append('set-cookie',`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`);
|
|
1479
|
+
}
|
|
1480
|
+
return response;
|
|
1481
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const OPERATIONAL_MIGRATION_FILE='0031_request_and_tool_notifications.sql';
|
|
2
|
+
export const OPERATIONAL_MIGRATION=`
|
|
3
|
+
-- Removed features are cleaned once, never on the request path.
|
|
4
|
+
DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*';
|
|
5
|
+
INSERT OR IGNORE INTO meta(key,value) VALUES('tool-data-revision','0');
|
|
6
|
+
${['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER tool_data_live_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
|
|
7
|
+
UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='tool-data-revision';
|
|
8
|
+
END;`).join('\n')}
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
export const CACHE_MIGRATION_FILE='0032_metadata_revisions.sql';
|
|
12
|
+
export const CACHE_MIGRATION=`
|
|
13
|
+
INSERT OR IGNORE INTO meta(key,value) VALUES('metadata-revision','0');
|
|
14
|
+
${['office_games','servers','chat_rules'].flatMap(table=>['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER ${table}_revision_${op.toLowerCase()} AFTER ${op} ON ${table} BEGIN
|
|
15
|
+
UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='metadata-revision';
|
|
16
|
+
END;`)).join('\n')}
|
|
17
|
+
`;
|
|
18
|
+
export const TOOL_NAMESPACE_MIGRATION_FILE='0033_tool_namespace_revisions.sql';
|
|
19
|
+
export const TOOL_NAMESPACE_MIGRATION=`
|
|
20
|
+
DROP TRIGGER tool_data_live_insert;
|
|
21
|
+
DROP TRIGGER tool_data_live_update;
|
|
22
|
+
DROP TRIGGER tool_data_live_delete;
|
|
23
|
+
DELETE FROM meta WHERE key='tool-data-revision';
|
|
24
|
+
CREATE TABLE tool_data_revisions(namespace TEXT PRIMARY KEY,revision INTEGER NOT NULL);
|
|
25
|
+
CREATE TABLE app_tool_namespaces(app TEXT NOT NULL,namespace TEXT NOT NULL,PRIMARY KEY(app,namespace));
|
|
26
|
+
${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `CREATE TRIGGER tool_data_namespace_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
|
|
27
|
+
INSERT INTO tool_data_revisions VALUES(${row}.namespace,1) ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;
|
|
28
|
+
${op==='UPDATE'?`INSERT INTO tool_data_revisions SELECT OLD.namespace,1 WHERE OLD.namespace!=NEW.namespace ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;`:''}
|
|
29
|
+
END;`;}).join('\n')}
|
|
30
|
+
`;
|
package/worker/pages.mjs
CHANGED
|
@@ -494,7 +494,13 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
494
494
|
ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
|
|
495
495
|
ui.querySelector('[data-room-label]').textContent = labelOf(room);
|
|
496
496
|
ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
|
|
497
|
-
|
|
497
|
+
var tv = new URL('/' + boot.game + '/tv', location.origin);
|
|
498
|
+
tv.searchParams.set('room', room);
|
|
499
|
+
if (boot.kind === 'app') {
|
|
500
|
+
var context = new URLSearchParams(location.search);
|
|
501
|
+
['role'].forEach(function (key) { if (context.has(key)) tv.searchParams.set(key, context.get(key)); });
|
|
502
|
+
}
|
|
503
|
+
ui.querySelector('[data-bigscreen]').href = tv.pathname + tv.search;
|
|
498
504
|
toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
|
|
499
505
|
ui.querySelector('[data-invite]').addEventListener('click', function () {
|
|
500
506
|
var data = { title: boot.name, text: (boot.appWords ? boot.appWords.open : 'Play') + ' ' + boot.name + ' with me: join my room.', url: link };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const ORIGINAL_DB=Symbol('original database');
|
|
2
|
+
export const MUTATIONS=Symbol('request mutations');
|
|
3
|
+
export const databaseBinding=db=>db?.[ORIGINAL_DB]??db;
|
|
4
|
+
const metadata=new WeakMap();
|
|
5
|
+
const metadataQueries=new Set(['SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500','SELECT * FROM servers LIMIT 2000','SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000']);
|
|
6
|
+
/** Cache only completed metadata, stamped by a trigger-maintained revision. Pending
|
|
7
|
+
* D1 operations belong to one request; unrelated metadata tables are never loaded.
|
|
8
|
+
*/
|
|
9
|
+
export function requestDatabase(DB, request, state={mutated:false}, binding=databaseBinding(DB)) {
|
|
10
|
+
if(!DB)return DB;
|
|
11
|
+
let revision,records;
|
|
12
|
+
const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
|
|
13
|
+
const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
|
|
14
|
+
const stamp=()=>revision??=(async()=>{
|
|
15
|
+
const query=DB.prepare("SELECT value FROM meta WHERE key='metadata-revision'");
|
|
16
|
+
if(recordQuery){const result=await DB.batch([query,DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined))]);records=result[1];return result[0].results?.[0]?.value??null;}
|
|
17
|
+
return (await query.first())?.value??null;
|
|
18
|
+
})();
|
|
19
|
+
const writing=sql=>{if(!/^\s*(SELECT|PRAGMA|EXPLAIN)\b/i.test(sql)){if(/\bapp_records\b/.test(sql)){records=null;revision=null;}if(/\b(office_games|servers|chat_rules)\b/.test(sql)){revision=null;metadata.delete(binding);}if(!/\bstats_daily\b/.test(sql))state.mutated=true;}};
|
|
20
|
+
const prepare=(sql,args=[])=>{
|
|
21
|
+
const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
|
|
22
|
+
const read=async()=>{
|
|
23
|
+
if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
|
|
24
|
+
if(!metadataQueries.has(sql))return original().all();
|
|
25
|
+
const version=await stamp();let cache=metadata.get(binding);
|
|
26
|
+
if(!cache||cache.version!==version){cache={version,rows:new Map()};metadata.set(binding,cache);}
|
|
27
|
+
if(version!==null&&cache.rows.has(sql))return cache.rows.get(sql);
|
|
28
|
+
const result=await original().all();if(version!==null)cache.rows.set(sql,result);return result;
|
|
29
|
+
};
|
|
30
|
+
return {bind:(...args)=>prepare(sql,args),first:async column=>{writing(sql);if(!metadataQueries.has(sql)&&sql!==recordQuery)return original().first(column);const row=(await read()).results?.[0]??null;return column&&row?row[column]:row;},all:()=>{writing(sql);return read();},run:()=>{writing(sql);return original().run();},raw:(...args)=>{writing(sql);return original().raw(...args);},_original:original,_sql:sql};
|
|
31
|
+
};
|
|
32
|
+
const methods={[ORIGINAL_DB]:binding,get [MUTATIONS](){return state.mutated;},prepare,batch:statements=>{for(const s of statements)writing(s._sql??'UPDATE');return DB.batch(statements.map(s=>s._original?.()??s));},exec:(sql,...args)=>{writing(sql);return DB.exec(sql,...args);},withSession:(...args)=>requestDatabase(DB.withSession(...args),request,state,binding)};
|
|
33
|
+
return new Proxy(methods,{get(target,key){if(key in target)return target[key];const value=DB[key];return typeof value==='function'?value.bind(DB):value;}});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A background task may register more work while it settles. */
|
|
37
|
+
export async function drainPending(pending) {
|
|
38
|
+
for(let done=0;done<pending.length;){const end=pending.length;await Promise.allSettled(pending.slice(done,end));done=end;}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep the runtime context intact and also deliver writes registered after the
|
|
42
|
+
* response's original drain finished (for example from response-stream work). */
|
|
43
|
+
export function trackBackground(ctx,deliver) {
|
|
44
|
+
const pending=[];let finished=false;
|
|
45
|
+
const context=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
|
|
46
|
+
if(key==='waitUntil')return work=>{
|
|
47
|
+
const task=Promise.resolve(work);pending.push(task);ctx.waitUntil(task);
|
|
48
|
+
if(finished)ctx.waitUntil(Promise.allSettled([task]).then(deliver));
|
|
49
|
+
};
|
|
50
|
+
const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
|
|
51
|
+
}}):ctx;
|
|
52
|
+
return {context,async finish(){await drainPending(pending);finished=true;await deliver();}};
|
|
53
|
+
}
|
package/worker/servers.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
|
|
3
4
|
* own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
|
|
@@ -171,8 +172,8 @@ const serverCache = new WeakMap();
|
|
|
171
172
|
/** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
|
|
172
173
|
async function rowsOf(env, { fresh = false } = {}) {
|
|
173
174
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
174
|
-
const hit = serverCache.get(env.DB);
|
|
175
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
175
|
+
const hit = serverCache.get(databaseBinding(env.DB));
|
|
176
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
176
177
|
let map = new Map();
|
|
177
178
|
try {
|
|
178
179
|
const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
|
|
@@ -182,10 +183,10 @@ async function rowsOf(env, { fresh = false } = {}) {
|
|
|
182
183
|
map.set(r.game, list);
|
|
183
184
|
}
|
|
184
185
|
} catch { map = new Map(); }
|
|
185
|
-
serverCache.set(env.DB, { at: Date.now(), map });
|
|
186
|
+
serverCache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
186
187
|
return map;
|
|
187
188
|
}
|
|
188
|
-
export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
|
|
189
|
+
export const forgetServers = (env) => { if (env?.DB) serverCache.delete(databaseBinding(env.DB)); };
|
|
189
190
|
|
|
190
191
|
/**
|
|
191
192
|
* A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's
|