@homie-rocks/studio 0.58.2 → 0.58.4
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 +34 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/worker/app-shell.mjs +11 -3
- package/worker/app-streams.mjs +27 -11
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +1 -2
- package/worker/lazy-objects.mjs +6 -0
- package/worker/lobby-peak.mjs +2 -1
- package/worker/lobby-shards.mjs +28 -0
- package/worker/lobby-summary.mjs +3 -1
- package/worker/lobby.mjs +30 -19
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +1 -1
- package/worker/mcp.mjs +4 -6
- package/worker/office.mjs +5 -3
- package/worker/routes.mjs +11 -0
- package/worker/runtime-support.mjs +5 -1
- package/worker/stats.mjs +11 -1
- package/worker/stream-objects.mjs +11 -3
- package/worker/table.mjs +2 -2
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,40 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.58.4] - 2026-10-18
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.2**
|
|
23
|
+
|
|
24
|
+
An open app rests while nobody is changing it. The live connection sleeps with the records object, and a studio MCP client no longer holds an event stream open.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- The open app keeps a hibernating WebSocket. The page renews its role cookie on the Worker every four minutes, so an idle app does not keep the records object awake.
|
|
29
|
+
- A GET of the studio MCP endpoint answers 405. The client treats that as no event stream and does not reconnect. Asking for `events/stream` still works for that one request, and it no longer sends a heartbeat that would wake the object again.
|
|
30
|
+
- 0.58.3 stopped the 15 second heartbeat. The open stream itself still held the object awake for as long as the page was open. This version closes that hold.
|
|
31
|
+
|
|
32
|
+
### Upgrade notes
|
|
33
|
+
|
|
34
|
+
- Upgrade the studio to pick this up. A page left open on the older app shell keeps asking for the old stream. The Worker answers 426 and does not wake the records object. Reload the page to open the socket.
|
|
35
|
+
|
|
36
|
+
## [0.58.3] - 2026-10-18
|
|
37
|
+
|
|
38
|
+
**Plugin 0.58.2**
|
|
39
|
+
|
|
40
|
+
An idle studio no longer keeps its lobby awake. Reading who is playing uses the summary already stored for the lounge, and a room that is merely sitting there lets its lobby shard hibernate.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Who is playing, the office and the room list read the lobby summary instead of waking all 16 lobby shards.
|
|
45
|
+
- A lobby shard publishes that summary in the report itself. It no longer sets a 10 second alarm, which had kept the shard awake for as long as anyone was seated.
|
|
46
|
+
- A room with nobody new arriving reports again after two and a half minutes, which is long enough for the shard to sleep and short enough that "playing now" still includes it.
|
|
47
|
+
- An open event stream or app stream no longer heartbeats every 15 seconds. That heartbeat was shorter than eviction, so the lobby stayed awake for as long as the page was open.
|
|
48
|
+
- The live count on a game page reads today's peak from the stats table. It no longer wakes the peak object every 15 seconds.
|
|
49
|
+
|
|
50
|
+
### Upgrade notes
|
|
51
|
+
|
|
52
|
+
- Upgrade the studio to pick this up. Until then, an open office or stats page still wakes every lobby shard.
|
|
53
|
+
|
|
20
54
|
## [0.58.2] - 2026-10-17
|
|
21
55
|
|
|
22
56
|
**Plugin 0.58.2**
|
|
@@ -4260,6 +4260,90 @@
|
|
|
4260
4260
|
}
|
|
4261
4261
|
}
|
|
4262
4262
|
}
|
|
4263
|
+
},
|
|
4264
|
+
"0.58.3": {
|
|
4265
|
+
"files": {
|
|
4266
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4267
|
+
"README.md": "684b3cca8001673c",
|
|
4268
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4269
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4270
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4271
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4272
|
+
"posts/README.md": "37841aae476bd540",
|
|
4273
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4274
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4275
|
+
},
|
|
4276
|
+
"sections": {
|
|
4277
|
+
"AGENTS.md": {
|
|
4278
|
+
"(intro)": "af7f2f732505d379",
|
|
4279
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4280
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4281
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4282
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4283
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4284
|
+
"## Making games": "bb715272fd1a615b",
|
|
4285
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4286
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4287
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4288
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4289
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4290
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4291
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4292
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4293
|
+
"## Beta": "9d02638207a26e73"
|
|
4294
|
+
}
|
|
4295
|
+
},
|
|
4296
|
+
"named": {
|
|
4297
|
+
"Homie Arcade": {
|
|
4298
|
+
"sections": {
|
|
4299
|
+
"AGENTS.md": {
|
|
4300
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
},
|
|
4306
|
+
"0.58.4": {
|
|
4307
|
+
"files": {
|
|
4308
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4309
|
+
"README.md": "684b3cca8001673c",
|
|
4310
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4311
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4312
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4313
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4314
|
+
"posts/README.md": "37841aae476bd540",
|
|
4315
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4316
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4317
|
+
},
|
|
4318
|
+
"sections": {
|
|
4319
|
+
"AGENTS.md": {
|
|
4320
|
+
"(intro)": "af7f2f732505d379",
|
|
4321
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4322
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4323
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4324
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4325
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4326
|
+
"## Making games": "bb715272fd1a615b",
|
|
4327
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4328
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4329
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4330
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4331
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4332
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4333
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4334
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4335
|
+
"## Beta": "9d02638207a26e73"
|
|
4336
|
+
}
|
|
4337
|
+
},
|
|
4338
|
+
"named": {
|
|
4339
|
+
"Homie Arcade": {
|
|
4340
|
+
"sections": {
|
|
4341
|
+
"AGENTS.md": {
|
|
4342
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4263
4347
|
}
|
|
4264
4348
|
}
|
|
4265
4349
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.4",
|
|
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/worker/app-shell.mjs
CHANGED
|
@@ -49,14 +49,22 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
49
49
|
}
|
|
50
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};}
|
|
51
51
|
function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
|
|
52
|
+
var delay=5000,stopped=false,renewTimer=null;
|
|
52
53
|
function connect(){
|
|
53
|
-
|
|
54
|
+
if(stopped)return;
|
|
55
|
+
try{if(live){live.onclose=null;live.close();}}catch(e){}
|
|
54
56
|
var q=query();collections.forEach(function(c){q.append('collection',c);});
|
|
55
|
-
|
|
57
|
+
var origin=boot.site||location.origin||'';
|
|
58
|
+
var http=(origin?origin:'')+'/'+boot.game+'/api/app/live?'+q;
|
|
59
|
+
// A hibernating socket. An idle page does not poll this object. The role cookie is renewed by an ordinary request.
|
|
60
|
+
live=new WebSocket(/^https?:/.test(http)?http.replace(/^http/,'ws'):http);
|
|
61
|
+
live.onopen=function(){delay=5000;};
|
|
56
62
|
live.onmessage=function(e){var value=JSON.parse(e.data);if(presence!==value.presence){presence=value.presence;sendContext();}if(value.collections?.length){share({invalidate:true});Promise.allSettled([...pending.values()]).then(function(){share({invalidate:true});frame?.contentWindow?.postMessage({type:'homie-app-changed',collections:value.collections},'*');});}};
|
|
63
|
+
live.onclose=function(e){if(stopped||(e&&e.code===4403))return;if(typeof setTimeout==='function'){setTimeout(connect,delay);delay=Math.min(delay*2,120000);}};
|
|
64
|
+
if(!renewTimer&&typeof setInterval==='function')renewTimer=setInterval(function(){fetch((boot.site||'')+'/'+boot.game+'/api/app/live?renew=1&'+query()).catch(function(){});},240000);
|
|
57
65
|
}
|
|
58
66
|
window.homieApp=Object.freeze({get context(){return context();},setTestClock:function(value){if(!boot.test)throw new Error('Local proof session required');if(value!==null&&!Number.isFinite(value))throw new Error('Invalid clock');clock=value;sendContext();}});
|
|
59
|
-
addEventListener('pagehide',function(){live
|
|
67
|
+
addEventListener('pagehide',function(){stopped=true;if(renewTimer)clearInterval(renewTimer);try{live&&live.close();}catch(e){}});
|
|
60
68
|
addEventListener('message', async function (e) {
|
|
61
69
|
var m = e.data;
|
|
62
70
|
if(frame&&e.source===frame.contentWindow&&m?.type==='homie-app-context'){sendContext();return;}
|
package/worker/app-streams.mjs
CHANGED
|
@@ -32,10 +32,22 @@ export async function pushAppRecords(env,cat){
|
|
|
32
32
|
export class AppRecordStreams {
|
|
33
33
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
34
34
|
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
35
|
-
close(s){if(!this.streams.delete(s))return;
|
|
35
|
+
close(s){if(!s||s.closed||!this.streams.delete(s))return;s.closed=true;clearTimeout(s.timer);clearTimeout(s.reconnect);try{s.controller?.close();}catch{/* The reader already cancelled or the stream already closed. */}try{if(s.socket&&s.socket.readyState!==3)s.socket.close(s.deny?4403:1000,'closed');}catch{/* already closed */}if(s.id)void this.ctx.storage?.delete?.('app-live:'+s.id);this.presence();}
|
|
36
|
+
async closeSocket(socket){await this.attachHibernated();const id=socket?.deserializeAttachment?.()?.id;const s=[...this.streams].find(x=>x.socket===socket||(id&&x.id===id));if(s)this.close(s);else if(id){void this.ctx.storage?.delete?.('app-live:'+id);this.presence();}}
|
|
36
37
|
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
37
|
-
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
38
|
+
send(s,data){const text=JSON.stringify(data);if(s.socket){try{s.socket.send(text);}catch{this.close(s);}return;}if(!s.controller||s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
38
39
|
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
40
|
+
async attachHibernated(){
|
|
41
|
+
if(!this.ctx.getWebSockets)return;
|
|
42
|
+
for(const socket of this.ctx.getWebSockets()){
|
|
43
|
+
const attachment=socket.deserializeAttachment?.();
|
|
44
|
+
if(!attachment||attachment.v!==1||[...this.streams].some(s=>s.id===attachment.id))continue;
|
|
45
|
+
const saved=await this.ctx.storage?.get?.('app-live:'+attachment.id);
|
|
46
|
+
if(!saved){try{socket.close(1011,'missing');}catch{/* already closed */}continue;}
|
|
47
|
+
const headers=saved.headers??{};
|
|
48
|
+
this.streams.add({...saved,seen:new Map(saved.seen??[]),socket,id:attachment.id,url:new URL(saved.url),request:new Request(saved.url,{headers}),meta:saved.meta});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
39
51
|
async authorized(s){
|
|
40
52
|
const access=await appAccess(s.request,this.env,s.meta,s.url);
|
|
41
53
|
if(!access.ok||s.collections.some(c=>!access.can.includes('read:'+c)))return false;
|
|
@@ -45,19 +57,23 @@ export class AppRecordStreams {
|
|
|
45
57
|
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;}
|
|
46
58
|
async open({meta,url,headers,collections=[]}){
|
|
47
59
|
if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
|
|
48
|
-
const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
|
|
60
|
+
const s={id:crypto.randomUUID(),meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
|
|
49
61
|
if(!await this.authorized(s))return new Response('role cannot read collections',{status:403});
|
|
50
62
|
const session=await localSession(s.request,this.env);
|
|
51
63
|
s.app=session?.app===meta.id&&session.scope?`${meta.id}:check:${session.scope}`:meta.id;
|
|
52
64
|
s.room=s.url.searchParams.get('room')??'main';
|
|
53
65
|
for(const row of await this.revisions(s))s.seen.set(row.collection,row.revision);
|
|
66
|
+
const cookie=headers?.cookie??headers?.Cookie;
|
|
67
|
+
if(this.ctx.storage?.put)await this.ctx.storage.put('app-live:'+s.id,{app:s.app,room:s.room,collections:s.collections,seen:[...s.seen],url:String(s.url),headers:cookie?{cookie}:{},meta});
|
|
68
|
+
const hello=()=>{this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});};
|
|
69
|
+
// A hibernating socket sleeps while the page is open. The browser renews the short role cookie on its own request, not from this object.
|
|
70
|
+
if(typeof WebSocketPair!=='undefined'&&this.ctx.acceptWebSocket){
|
|
71
|
+
const pair=new WebSocketPair(),client=pair[0]??Object.values(pair)[0],server=pair[1]??Object.values(pair)[1];
|
|
72
|
+
s.socket=server;server.serializeAttachment?.({v:1,id:s.id});this.ctx.acceptWebSocket(server);hello();
|
|
73
|
+
return new Response(null,{status:101,webSocket:client,headers:{'cache-control':'no-store'}});
|
|
74
|
+
}
|
|
54
75
|
const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
|
|
55
|
-
|
|
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);
|
|
59
|
-
// Recovery after a missed wake; this is not the screen's refresh clock.
|
|
60
|
-
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s,true)),15000);
|
|
76
|
+
hello();
|
|
61
77
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
62
78
|
}
|
|
63
79
|
async pump(s,recheck=false){
|
|
@@ -66,9 +82,9 @@ export class AppRecordStreams {
|
|
|
66
82
|
const changed=new Set();
|
|
67
83
|
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
84
|
if(!changed.size&&!recheck)return;
|
|
69
|
-
if(!await this.authorized(s)){this.close(s);return;}
|
|
85
|
+
if(!await this.authorized(s)){s.deny=true;this.close(s);return;}
|
|
70
86
|
this.send(s,{collections:[...changed],presence:this.count(s)});
|
|
71
87
|
}catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
|
|
72
88
|
}
|
|
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');}
|
|
89
|
+
async wake(meta){await this.attachHibernated();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');}
|
|
74
90
|
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.4","sha256":"b84e08e7d2231f25b1b37bde4dd66be05b02a2bc500a601740de08b15b437738"});
|
|
@@ -93,8 +93,7 @@ export class EventStreams {
|
|
|
93
93
|
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
94
94
|
stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
95
95
|
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) ON CONFLICT(key) DO UPDATE SET value=excluded.value,expires=excluded.expires').bind('tools-stream:'+connection,JSON.stringify({connection}),Math.floor(Date.now()/1000)+300).run();
|
|
96
|
-
|
|
97
|
-
stream.reconnect?.unref?.();
|
|
96
|
+
// No timer. Production MCP GET never opens this. A caller that does is holding its own request.
|
|
98
97
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
99
98
|
// hold up the writer's request or a function handler.
|
|
100
99
|
this.background(input);
|
package/worker/lazy-objects.mjs
CHANGED
|
@@ -16,6 +16,9 @@ export class Lobby {
|
|
|
16
16
|
}
|
|
17
17
|
async fetch(request) {await this.ready;return this.lobby.fetch(request);}
|
|
18
18
|
async alarm() {await this.ready;return this.lobby.alarm();}
|
|
19
|
+
async webSocketMessage(...args) {await this.ready;return this.lobby.webSocketMessage(...args);}
|
|
20
|
+
async webSocketClose(...args) {await this.ready;return this.lobby.webSocketClose(...args);}
|
|
21
|
+
async webSocketError(...args) {await this.ready;return this.lobby.webSocketError(...args);}
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export class Gate {
|
|
@@ -34,6 +37,9 @@ export class AppRecordHub {
|
|
|
34
37
|
this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).AppRecordHub(ctx,env);});
|
|
35
38
|
}
|
|
36
39
|
async fetch(request) {await this.ready;return this.hub.fetch(request);}
|
|
40
|
+
async webSocketMessage(...args) {await this.ready;return this.hub.webSocketMessage(...args);}
|
|
41
|
+
async webSocketClose(...args) {await this.ready;return this.hub.webSocketClose(...args);}
|
|
42
|
+
async webSocketError(...args) {await this.ready;return this.hub.webSocketError(...args);}
|
|
37
43
|
}
|
|
38
44
|
export class EventHub {
|
|
39
45
|
constructor(ctx,env) {
|
package/worker/lobby-peak.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {counter,today} from './stats.mjs';
|
|
2
|
+
import {PRESENCE_MS} from './lobby-summary.mjs';
|
|
2
3
|
|
|
3
4
|
// One total per bookkeeping shard, never a sum of independent historical peaks.
|
|
4
5
|
// Reports carry their observation time so a delayed request cannot rewind a shard.
|
|
@@ -32,7 +33,7 @@ export class LobbyPeak {
|
|
|
32
33
|
if(!Number.isInteger(row.shard)||row.shard<0||row.shard>=16||!Number.isFinite(row.at)||!Number.isFinite(row.players)||row.players<0)return Response.json({error:'report'},{status:400});
|
|
33
34
|
if(!prior||row.at>prior.at){
|
|
34
35
|
state.shards[row.shard]={players:row.players,at:row.at};
|
|
35
|
-
const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-
|
|
36
|
+
const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-PRESENCE_MS).reduce((n,r)=>n+r.players,0);
|
|
36
37
|
const players=Math.max(state.players,concurrent),room=Math.max(state.room,Number(row.room)||0);
|
|
37
38
|
if(players>state.players||room>state.room)this.dirty=true;
|
|
38
39
|
state.players=players;state.room=room;this.subject=new URL(request.url).searchParams.get('game');
|
package/worker/lobby-shards.mjs
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import {roomServer} from './servers.mjs';
|
|
2
|
+
import {liveRoomSummaries} from './lobby-summary.mjs';
|
|
3
|
+
/** Today's high water, from the counter the peak object already wrote. A read must not wake that object. */
|
|
4
|
+
async function peakFromStats(env,game){
|
|
5
|
+
const peak={players:0,room:0};
|
|
6
|
+
if(!env.DB)return peak;
|
|
7
|
+
try{
|
|
8
|
+
const day=new Date().toISOString().slice(0,10);
|
|
9
|
+
const {results}=await env.DB.prepare("SELECT metric, MAX(n) AS n FROM stats_daily WHERE day=?1 AND subject=?2 AND metric IN ('peak','peak-room') GROUP BY metric").bind(day,game).all();
|
|
10
|
+
for(const row of results??[]){
|
|
11
|
+
if(row.metric==='peak')peak.players=Number(row.n)||0;
|
|
12
|
+
if(row.metric==='peak-room')peak.room=Number(row.n)||0;
|
|
13
|
+
}
|
|
14
|
+
}catch{/* before the stats table, the number is zero and the peak object stays asleep */}
|
|
15
|
+
return peak;
|
|
16
|
+
}
|
|
2
17
|
export const LOBBY_SHARDS=16;
|
|
3
18
|
export const SHARDED_ROOM_BASE=100000000;
|
|
4
19
|
export function lobbyShard(key) {
|
|
@@ -28,6 +43,19 @@ export function lobbyFor(env,game,identity='') {
|
|
|
28
43
|
const body=JSON.parse(init?.body??await input.clone().text());
|
|
29
44
|
return send(roomShard(body.room),url,init??{method:'POST',body:JSON.stringify(body)});
|
|
30
45
|
}
|
|
46
|
+
// A read must not wake every shard. The shards write lobby_summaries when they take a report; one D1 read answers the office, the stats page and the lounge. /busiest still asks the shards: a watcher is matched on the live build, which the summary does not carry.
|
|
47
|
+
if(url.pathname==='/now'||url.pathname==='/rooms'||url.pathname==='/office') {
|
|
48
|
+
const summaries=await liveRoomSummaries(env);
|
|
49
|
+
if(summaries){
|
|
50
|
+
const list=summaries.get(game)??[];
|
|
51
|
+
if(url.pathname==='/rooms')return Response.json({rooms:list});
|
|
52
|
+
if(url.pathname==='/office'){
|
|
53
|
+
const rooms=list.map(r=>({room:r.name??r.room,players:r.players||0,people:r.people??r.players??0,agents:r.agents??0,ai:r.ai??0,at:r.at,server:r.server??roomServer(r.name??r.room),...(r.closedUntil?{closedUntil:r.closedUntil}:{})})).filter(r=>(r.players||0)>0||r.closedUntil).sort((a,b)=>b.players-a.players).slice(0,100);
|
|
54
|
+
return Response.json({rooms});
|
|
55
|
+
}
|
|
56
|
+
return Response.json({players:list.reduce((n,r)=>n+(Number(r.players)||0),0),rooms:list.filter(r=>(r.players||0)>0).length,peak:await peakFromStats(env,game)});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
31
59
|
const rows=await Promise.all(Array.from({length:LOBBY_SHARDS},async(_,shard)=>{
|
|
32
60
|
const response=await send(shard,url,init);if(!response.ok)throw Error('Lobby shard unavailable');return response.json();
|
|
33
61
|
}));
|
package/worker/lobby-summary.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How long a room still counts as live after its last report. Longer than the lobby heartbeat, so a quiet room stays listed while its shard hibernates. */
|
|
2
|
+
export const PRESENCE_MS=240000;
|
|
1
3
|
export const LIVE_ROOMS_QUERY='SELECT game,rooms,updated FROM lobby_summaries ORDER BY game LIMIT 500';
|
|
2
4
|
/** Queue order is not guaranteed. An older snapshot must never replace a newer one. */
|
|
3
5
|
export async function storeLobbySummary(env, message) {
|
|
@@ -10,7 +12,7 @@ export async function storeLobbySummary(env, message) {
|
|
|
10
12
|
}
|
|
11
13
|
export async function liveRoomSummaries(env) {
|
|
12
14
|
if(!env.BACKGROUND||!env.DB)return null; // Older wrappers still query their Lobby.
|
|
13
|
-
const result=new Map(),since=Date.now()-
|
|
15
|
+
const result=new Map(),since=Date.now()-PRESENCE_MS;
|
|
14
16
|
let cursor=null;
|
|
15
17
|
for(;;){
|
|
16
18
|
const query=cursor===null?env.DB.prepare(LIVE_ROOMS_QUERY):env.DB.prepare('SELECT game,rooms,updated FROM lobby_summaries WHERE game>? ORDER BY game LIMIT 500').bind(cursor);
|
package/worker/lobby.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { ROOM_ID } from './page-policy.mjs';
|
|
|
8
8
|
import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
|
|
9
9
|
import { seatCount } from './seats.mjs';
|
|
10
10
|
import { counter, today } from './stats.mjs';
|
|
11
|
+
import { PRESENCE_MS } from './lobby-summary.mjs';
|
|
11
12
|
|
|
12
13
|
import {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
|
|
13
14
|
export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
|
|
@@ -34,7 +35,7 @@ export class Lobby {
|
|
|
34
35
|
this.current = saved.current ?? null;
|
|
35
36
|
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
36
37
|
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
|
|
37
|
-
for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-
|
|
38
|
+
for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-PRESENCE_MS)this.live.set(r.name,{players:r.players,at:r.at});
|
|
38
39
|
if(ctx.storage.sql)for(const row of ctx.storage.sql.exec('SELECT body,expires FROM matchmaking_reservations WHERE expires>?',Date.now()).toArray()) {
|
|
39
40
|
const reservation=JSON.parse(row.body),room=this.rooms.get(reservation.room);
|
|
40
41
|
if(room && reservation.pending!==false)room.pending.push(row.expires-20000);
|
|
@@ -51,7 +52,7 @@ export class Lobby {
|
|
|
51
52
|
notePeak(room, players, game) {
|
|
52
53
|
const now = Date.now();
|
|
53
54
|
if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
|
|
54
|
-
for (const [name, r] of this.live) if (now - r.at >
|
|
55
|
+
for (const [name, r] of this.live) if (now - r.at > PRESENCE_MS) this.live.delete(name); // a room that stopped reporting
|
|
55
56
|
const day = today();
|
|
56
57
|
if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
|
|
57
58
|
let all = 0;
|
|
@@ -87,10 +88,14 @@ export class Lobby {
|
|
|
87
88
|
if(new URL(request.url).pathname==='/peak')return (this.peakStore??=new LobbyPeak(this.ctx,this.env)).fetch(request);
|
|
88
89
|
if(['/match','/match-policy'].includes(new URL(request.url).pathname))return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).fetch(request);
|
|
89
90
|
if(new URL(request.url).pathname==='/__app_records') {
|
|
90
|
-
const
|
|
91
|
-
|
|
91
|
+
const streams=await this.liveStreams();
|
|
92
|
+
if(request.headers.get('upgrade')?.toLowerCase()==='websocket'){
|
|
93
|
+
const meta=JSON.parse(request.headers.get('x-homie-app-meta')||'{}');
|
|
94
|
+
const page=request.headers.get('x-homie-live-page')||request.url;
|
|
95
|
+
return streams.open({meta,url:page,headers:Object.fromEntries(request.headers),collections:new URL(request.url).searchParams.getAll('collection')});
|
|
96
|
+
}
|
|
92
97
|
const input=await request.json();
|
|
93
|
-
return input.op==='open'?
|
|
98
|
+
return input.op==='open'?streams.open(input):streams.wake(input.meta);
|
|
94
99
|
}
|
|
95
100
|
if(new URL(request.url).pathname==='/__studio_events') {
|
|
96
101
|
const {EventStreams}=await import('./event-resources.mjs');
|
|
@@ -99,7 +104,11 @@ export class Lobby {
|
|
|
99
104
|
}
|
|
100
105
|
try {
|
|
101
106
|
const response=await this.answer(request),url=new URL(request.url);
|
|
102
|
-
if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game'))
|
|
107
|
+
if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game')) {
|
|
108
|
+
const game=url.searchParams.get('game'),shard=Number(url.searchParams.get('shard'))||0;
|
|
109
|
+
// Publish in this wake. A +10s alarm restarts the idle clock and, with a heartbeat inside the eviction window, pins the shard for as long as anyone is seated.
|
|
110
|
+
this.ctx.waitUntil(this.publishSummary(game,shard).catch(error=>console.error(JSON.stringify({ev:'lobby-summary-failed',error:errorLine(error)}))));
|
|
111
|
+
}
|
|
103
112
|
return response;
|
|
104
113
|
} catch (error) {
|
|
105
114
|
let path = '';
|
|
@@ -109,24 +118,26 @@ export class Lobby {
|
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
async
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
121
|
+
async publishSummary(game,shard=0) {
|
|
122
|
+
if(!game||!this.env?.BACKGROUND)return;
|
|
123
|
+
const at=Date.now();
|
|
124
|
+
const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-PRESENCE_MS).map(({name,players,agents,ai,server,at:reported})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at:reported}));
|
|
125
|
+
for(const [name,until] of this.closed)if(until>at&&!rooms.some(r=>r.name===name))rooms.push({name,players:0,agents:0,ai:0,server:roomServer(name),at,closedUntil:until});
|
|
126
|
+
await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
|
|
119
127
|
}
|
|
128
|
+
async liveStreams(){const {AppRecordStreams}=await import('./app-streams.mjs');return this.appStreams??=new AppRecordStreams(this.env,this.ctx);}
|
|
129
|
+
webSocketMessage(){}
|
|
130
|
+
async webSocketClose(socket){return (await this.liveStreams()).closeSocket(socket);}
|
|
131
|
+
async webSocketError(socket){return (await this.liveStreams()).closeSocket(socket);}
|
|
120
132
|
async alarm() {
|
|
121
133
|
if(this.matchRouter)return this.matchRouter.alarm();
|
|
122
134
|
if((await this.ctx.storage.list?.({prefix:'match:',limit:1}))?.size)return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).alarm();
|
|
135
|
+
// A summary alarm armed by an older build. Publish once and do not arm another: an idle shard has to be able to hibernate.
|
|
123
136
|
this.summaryDue=null;
|
|
124
137
|
const saved=await this.ctx.storage.get('summary-game');
|
|
125
138
|
const game=this.summaryGame??(typeof saved==='string'?saved:saved?.game),shard=this.summaryShard??saved?.shard??0;
|
|
126
|
-
if(
|
|
127
|
-
|
|
128
|
-
const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-180000).map(({name,players,agents,ai,server,at})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at}));
|
|
129
|
-
await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
|
|
139
|
+
if(saved)await this.ctx.storage.delete('summary-game').catch(()=>{});
|
|
140
|
+
await this.publishSummary(game,shard);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
reservation(key,result) {
|
|
@@ -303,7 +314,7 @@ export class Lobby {
|
|
|
303
314
|
}
|
|
304
315
|
if (url.pathname === '/office') {
|
|
305
316
|
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
306
|
-
for (const [name, r] of this.seen) if (now - r.at >
|
|
317
|
+
for (const [name, r] of this.seen) if (now - r.at > PRESENCE_MS) this.seen.delete(name);
|
|
307
318
|
const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
|
|
308
319
|
for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
|
|
309
320
|
for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
|
|
@@ -311,7 +322,7 @@ export class Lobby {
|
|
|
311
322
|
}
|
|
312
323
|
if (url.pathname === '/now') {
|
|
313
324
|
let players = 0;
|
|
314
|
-
for (const [name, r] of this.live) { if (Date.now() - r.at >
|
|
325
|
+
for (const [name, r] of this.live) { if (Date.now() - r.at > PRESENCE_MS) this.live.delete(name); else players += r.players; }
|
|
315
326
|
return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
|
|
316
327
|
}
|
|
317
328
|
return json({ error: 'not found' }, 404);
|
|
@@ -4,7 +4,7 @@ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseF
|
|
|
4
4
|
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
5
|
export class McpEventStreams {
|
|
6
6
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
-
close(stream){if(stream.closed)return;stream.closed=true;
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearTimeout(stream.timer);clearTimeout(stream.life);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(error=>console.warn("MCP stream cleanup deferred to expiry:",error.message)));}
|
|
8
8
|
async write(stream,value,authorize){
|
|
9
9
|
if(stream.closed)return false;
|
|
10
10
|
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
@@ -23,8 +23,8 @@ export class McpEventStreams {
|
|
|
23
23
|
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
24
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
25
|
this.streams.add(stream);
|
|
26
|
-
//
|
|
27
|
-
stream.
|
|
26
|
+
// The open body is this request. Cap it so a client that never cancels cannot hold the object past five minutes, and do not wake it again to write heartbeats.
|
|
27
|
+
stream.life=setTimeout(()=>this.close(stream),300000);
|
|
28
28
|
this.pump(stream);
|
|
29
29
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
30
30
|
}catch(error){return Response.json({jsonrpc:'2.0',id:input.id,error:eventError(error)});}
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
|
45
45
|
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
46
|
const events=page.map(row=>{const data=JSON.parse(row.data);if((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
47
47
|
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
|
-
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:
|
|
48
|
+
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:180000};
|
|
49
49
|
}
|
|
50
50
|
async function subId(caller,params,url){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([caller.id,url,params.name,{}])));return 'sub_'+Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');}
|
|
51
51
|
async function stored(env,id){const row=await env.DB.prepare('SELECT value FROM mcp_kv WHERE key=?1').bind(prefix+id).first();return row?JSON.parse(row.value):null;}
|
package/worker/mcp.mjs
CHANGED
|
@@ -9,7 +9,7 @@ function freshErrorMessage(error,env,caller) {
|
|
|
9
9
|
return message;
|
|
10
10
|
}
|
|
11
11
|
import {localCaller} from './local-test.mjs';
|
|
12
|
-
import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,
|
|
12
|
+
import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,pushToolList} from './event-resources.mjs';
|
|
13
13
|
import { selling } from './extensions.mjs';
|
|
14
14
|
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
15
15
|
import { createMcpHandler } from 'agents/mcp/server';
|
|
@@ -73,11 +73,9 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
73
73
|
const refresh=pushToolList(env,cat,tools,origin);
|
|
74
74
|
if(ctx?.waitUntil)ctx.waitUntil(refresh);
|
|
75
75
|
else await refresh;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'stream',connection,lastId:request.headers.get('last-event-id'),cat,origin,tools:tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}))})});
|
|
80
|
-
}
|
|
76
|
+
// A standing GET is the MCP SSE stream. Holding it pins the event object for as long as the client stays connected.
|
|
77
|
+
// 405 tells the client there is no stream, and it does not reconnect. Events stay on resources/read and events/poll.
|
|
78
|
+
if(request.method==='GET')return new Response(null,{status:405,headers:{allow:'POST, DELETE','cache-control':'no-store'}});
|
|
81
79
|
if(request.method==='POST') {
|
|
82
80
|
const body=await request.clone().json().catch(()=>null);
|
|
83
81
|
if(eventMethods.has(body?.method))return eventsRequest(request,env,ctx,cat,caller,body);
|
package/worker/office.mjs
CHANGED
|
@@ -1469,10 +1469,12 @@ export async function appRoleSession(response,request,env,meta,launch) {
|
|
|
1469
1469
|
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
1470
1470
|
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'});
|
|
1471
1471
|
if(ticket) {
|
|
1472
|
-
response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
|
|
1473
|
-
// LAN dev uses HTTP: a Secure cookie is discarded before the shell removes access.
|
|
1474
1472
|
const secure=new URL(request.url).protocol==='https:'?'; Secure':'';
|
|
1475
|
-
|
|
1473
|
+
const cookie=`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`;
|
|
1474
|
+
// Rebuilding a 101 drops the hibernating socket. Append the cookie on the response the object returned.
|
|
1475
|
+
if(response.status===101&&response.webSocket){response.headers.append('set-cookie',cookie);return response;}
|
|
1476
|
+
response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
|
|
1477
|
+
response.headers.append('set-cookie',cookie);
|
|
1476
1478
|
}
|
|
1477
1479
|
return response;
|
|
1478
1480
|
}
|
package/worker/routes.mjs
CHANGED
|
@@ -325,7 +325,18 @@ export default async function route(request, env, ctx) {
|
|
|
325
325
|
};
|
|
326
326
|
if(sub==='api/app/live') {
|
|
327
327
|
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
328
|
+
// Cookie renewal is a Worker response. It must not wake the records object.
|
|
329
|
+
if(url.searchParams.get('renew')==='1')return apiSession(new Response(null,{status:204,headers:{'cache-control':'no-store'}}));
|
|
328
330
|
const {appHub}=await import('./app-streams.mjs');
|
|
331
|
+
if(typeof WebSocketPair!=='undefined'){
|
|
332
|
+
if(request.headers.get('upgrade')?.toLowerCase()!=='websocket')return new Response('websocket required',{status:426,headers:{'cache-control':'no-store'}});
|
|
333
|
+
// The object has to answer the upgrade itself. A JSON subrequest cannot carry the socket back.
|
|
334
|
+
const headers=new Headers(request.headers);
|
|
335
|
+
headers.set('x-homie-live-page',request.url);
|
|
336
|
+
headers.set('x-homie-app-meta',JSON.stringify({id:meta.id,kind:meta.kind,roles:meta.roles,records:meta.records,surfaces:meta.surfaces,launch:meta.launch}));
|
|
337
|
+
const target=new URL('https://app/__app_records');target.search=new URL(request.url).search;
|
|
338
|
+
return apiSession(await appHub(env,meta.id).fetch(new Request(target,{headers})));
|
|
339
|
+
}
|
|
329
340
|
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')})}));
|
|
330
341
|
}
|
|
331
342
|
if(sub==='api/app/tools')return apiSession(await (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools()));
|
|
@@ -429,8 +429,12 @@ async function serverLive(env, meta, servers, origin, lobby) {
|
|
|
429
429
|
async function weekOf(env, game) {
|
|
430
430
|
const w = await playedThisWeek(env, game);
|
|
431
431
|
if (!w) return null;
|
|
432
|
+
// Today's high water is already in stats_daily. Reading the peak object here woke it on every 15s live poll, which is shorter than eviction, so the object never slept while a game page was open.
|
|
432
433
|
let peak = 0;
|
|
433
|
-
try {
|
|
434
|
+
try {
|
|
435
|
+
const row = await env.DB.prepare("SELECT MAX(n) AS n FROM stats_daily WHERE day = ?1 AND metric = 'peak' AND subject = ?2").bind(today(), String(game)).first();
|
|
436
|
+
peak = Number(row?.n) || 0;
|
|
437
|
+
} catch { peak = 0; }
|
|
434
438
|
return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
|
|
435
439
|
}
|
|
436
440
|
|
package/worker/stats.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {lobbyFor} from './lobby-shards.mjs';
|
|
2
|
+
import {liveRoomSummaries} from './lobby-summary.mjs';
|
|
2
3
|
import {writePageMetric,analyticsStatus} from './analytics.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
|
|
@@ -251,8 +252,17 @@ export async function playersNow(env) {
|
|
|
251
252
|
} catch { return null; }
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
/**
|
|
255
|
+
/** People in every room of each game, right now. The summary table when this worker has one, so a stats read does not wake a lobby shard. */
|
|
255
256
|
async function playingNow(env, games) {
|
|
257
|
+
const summaries = await liveRoomSummaries(env).catch(() => null);
|
|
258
|
+
if (summaries) {
|
|
259
|
+
const out = {};
|
|
260
|
+
for (const g of games) {
|
|
261
|
+
const list = summaries.get(g.id) ?? [];
|
|
262
|
+
out[g.id] = { players: list.reduce((n, r) => n + (Number(r.players) || 0), 0), rooms: list.filter((r) => (r.players || 0) > 0).length };
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
256
266
|
const out = {};
|
|
257
267
|
await Promise.all(games.map(async (g) => {
|
|
258
268
|
try {
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/** Streams use independent namespaces so subscribers never warm matchmaking state. */
|
|
2
2
|
export class AppRecordHub {
|
|
3
3
|
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
4
|
+
async records(){const {AppRecordStreams}=await import('./app-streams.mjs');return this.streams??=new AppRecordStreams(this.env,this.ctx);}
|
|
4
5
|
async fetch(request) {
|
|
5
|
-
const
|
|
6
|
-
|
|
6
|
+
const streams=await this.records();
|
|
7
|
+
if(request.headers.get('upgrade')?.toLowerCase()==='websocket'){
|
|
8
|
+
const meta=JSON.parse(request.headers.get('x-homie-app-meta')||'{}');
|
|
9
|
+
const page=request.headers.get('x-homie-live-page')||request.url;
|
|
10
|
+
return streams.open({meta,url:page,headers:Object.fromEntries(request.headers),collections:new URL(request.url).searchParams.getAll('collection')});
|
|
11
|
+
}
|
|
7
12
|
const input=await request.json();
|
|
8
|
-
return input.op==='open'?
|
|
13
|
+
return input.op==='open'?streams.open(input):streams.wake(input.meta);
|
|
9
14
|
}
|
|
15
|
+
webSocketMessage(){}
|
|
16
|
+
async webSocketClose(socket){return (await this.records()).closeSocket(socket);}
|
|
17
|
+
async webSocketError(socket){return (await this.records()).closeSocket(socket);}
|
|
10
18
|
}
|
|
11
19
|
export class EventHub {
|
|
12
20
|
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
package/worker/table.mjs
CHANGED
|
@@ -1447,8 +1447,8 @@ export class Table {
|
|
|
1447
1447
|
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
1448
1448
|
let people = 0;
|
|
1449
1449
|
for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
|
|
1450
|
-
// On a change, and
|
|
1451
|
-
if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) <
|
|
1450
|
+
// On a change, and every two and a half minutes while anybody is here. That is longer than Durable Object eviction, so an occupied room's lobby shard hibernates between heartbeats, and shorter than the presence window, so "playing now" does not drop a live room.
|
|
1451
|
+
if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 150_000)) return;
|
|
1452
1452
|
this.lastReport = players;
|
|
1453
1453
|
this.lastPeople = people;
|
|
1454
1454
|
this.lastAgents = agents;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.4';
|