@homie-rocks/studio 0.58.3 → 0.58.5

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 CHANGED
@@ -17,6 +17,38 @@ 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.5] - 2026-10-18
21
+
22
+ **Plugin 0.58.2**
23
+
24
+ A signed-in app gets its live socket. Leaving that page open no longer wakes the records object.
25
+
26
+ ### Fixed
27
+
28
+ - The live upgrade is returned as the records object sent it. Writing the role cookie onto that response threw, so the browser never received the socket and tried again about every two minutes.
29
+ - When the stored role cookie no longer works, the socket closes and the page opens a new one with the cookie it renewed. The page does not give up.
30
+ - Someone who opens the app is counted with the people whose sockets are already asleep.
31
+
32
+ ### Upgrade notes
33
+
34
+ - Upgrade the studio to pick this up.
35
+
36
+ ## [0.58.4] - 2026-10-18
37
+
38
+ **Plugin 0.58.2**
39
+
40
+ 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.
41
+
42
+ ### Fixed
43
+
44
+ - 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.
45
+ - 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.
46
+ - 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.
47
+
48
+ ### Upgrade notes
49
+
50
+ - 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.
51
+
20
52
  ## [0.58.3] - 2026-10-18
21
53
 
22
54
  **Plugin 0.58.2**
@@ -4302,6 +4302,90 @@
4302
4302
  }
4303
4303
  }
4304
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
+ }
4347
+ },
4348
+ "0.58.5": {
4349
+ "files": {
4350
+ "CLAUDE.md": "ddf2b2a0376202f6",
4351
+ "README.md": "684b3cca8001673c",
4352
+ "HANDOFF.md": "29337e2ace95b2e7",
4353
+ "games/README.md": "5ad32fe80c61f117",
4354
+ "music/README.md": "a3dc042dadd39df8",
4355
+ "videos/README.md": "16babc3437d5545b",
4356
+ "posts/README.md": "37841aae476bd540",
4357
+ "site/README.md": "1dbdf22c03a1e505",
4358
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4359
+ },
4360
+ "sections": {
4361
+ "AGENTS.md": {
4362
+ "(intro)": "af7f2f732505d379",
4363
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4364
+ "## Layout": "f3a280b8c96cd068",
4365
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4366
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4367
+ "## The site": "0cb8dd9a6f8a0096",
4368
+ "## Making games": "bb715272fd1a615b",
4369
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4370
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4371
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4372
+ "## Servers and AI seats": "1ccfd549d2369a60",
4373
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4374
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4375
+ "## Rules": "b2234d80f6cbe71d",
4376
+ "## Telling Homie": "80b46fdd9e578db8",
4377
+ "## Beta": "9d02638207a26e73"
4378
+ }
4379
+ },
4380
+ "named": {
4381
+ "Homie Arcade": {
4382
+ "sections": {
4383
+ "AGENTS.md": {
4384
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4385
+ }
4386
+ }
4387
+ }
4388
+ }
4305
4389
  }
4306
4390
  }
4307
4391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.58.3",
3
+ "version": "0.58.5",
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",
@@ -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
- live?.close();
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
- live=new EventSource((boot.site||'')+'/'+boot.game+'/api/app/live?'+q);
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?.close();});
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;}
@@ -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;clearTimeout(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
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,24 @@ 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
+ await this.attachHibernated();
69
+ 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)});};
70
+ // A hibernating socket sleeps while the page is open. The browser renews the short role cookie on its own request, not from this object.
71
+ if(typeof WebSocketPair!=='undefined'&&this.ctx.acceptWebSocket){
72
+ const pair=new WebSocketPair(),client=pair[0]??Object.values(pair)[0],server=pair[1]??Object.values(pair)[1];
73
+ s.socket=server;server.serializeAttachment?.({v:1,id:s.id});this.ctx.acceptWebSocket(server);hello();
74
+ return new Response(null,{status:101,webSocket:client,headers:{'cache-control':'no-store'}});
75
+ }
54
76
  const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
55
- this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
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
- // One recovery check, past eviction, so an open app does not pin its object. The screen reconnects after the cap below.
60
- s.timer=setTimeout(()=>this.ctx.waitUntil(this.pump(s,true)),180000);
77
+ hello();
61
78
  return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
62
79
  }
63
80
  async pump(s,recheck=false){
@@ -66,9 +83,10 @@ export class AppRecordStreams {
66
83
  const changed=new Set();
67
84
  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
85
  if(!changed.size&&!recheck)return;
86
+ // 1000, not 4403. The stored handoff expires; the page opens a new socket with the cookie it just renewed.
69
87
  if(!await this.authorized(s)){this.close(s);return;}
70
88
  this.send(s,{collections:[...changed],presence:this.count(s)});
71
89
  }catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
72
90
  }
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');}
91
+ 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
92
  }
@@ -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.3","sha256":"e8ea8077d2bad80f19cf3fc5f7a69f0d8ea458f20a880b28442ed23a756944e6"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.5","sha256":"1af6197dc4435458f1241b39c4cc1d26031baaa88e5258fa6ead03f73f5750fe"});
@@ -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
- stream.reconnect=setTimeout(()=>this.close(stream),240000);
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);
@@ -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.mjs CHANGED
@@ -88,10 +88,14 @@ export class Lobby {
88
88
  if(new URL(request.url).pathname==='/peak')return (this.peakStore??=new LobbyPeak(this.ctx,this.env)).fetch(request);
89
89
  if(['/match','/match-policy'].includes(new URL(request.url).pathname))return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).fetch(request);
90
90
  if(new URL(request.url).pathname==='/__app_records') {
91
- const {AppRecordStreams}=await import('./app-streams.mjs');
92
- this.appStreams??=new AppRecordStreams(this.env,this.ctx);
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
+ }
93
97
  const input=await request.json();
94
- return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
98
+ return input.op==='open'?streams.open(input):streams.wake(input.meta);
95
99
  }
96
100
  if(new URL(request.url).pathname==='/__studio_events') {
97
101
  const {EventStreams}=await import('./event-resources.mjs');
@@ -121,6 +125,10 @@ export class Lobby {
121
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});
122
126
  await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
123
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);}
124
132
  async alarm() {
125
133
  if(this.matchRouter)return this.matchRouter.alarm();
126
134
  if((await this.ctx.storage.list?.({prefix:'match:',limit:1}))?.size)return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).alarm();
@@ -23,8 +23,7 @@ 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
- // One heartbeat is enough to recheck authorization. A repeating timer, shorter than eviction, pins this lobby for as long as the page holds the stream.
27
- stream.timer=setTimeout(()=>this.pump(stream),180000);
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.
28
27
  stream.life=setTimeout(()=>this.close(stream),300000);
29
28
  this.pump(stream);
30
29
  return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
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,eventHub,pushToolList} from './event-resources.mjs';
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
- if(request.method==='GET'&&env.LOBBY) {
77
- if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
78
- const connection=caller.connection??'public:'+crypto.randomUUID();
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,13 @@ 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
- response.headers.append('set-cookie',`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`);
1473
+ const cookie=`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`;
1474
+ // The upgrade response's headers are immutable, and appending throws before the browser receives the socket.
1475
+ // The document and the renew request already set this cookie. Return the socket unchanged.
1476
+ if(response.status===101&&response.webSocket)return response;
1477
+ response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
1478
+ response.headers.append('set-cookie',cookie);
1476
1479
  }
1477
1480
  return response;
1478
1481
  }
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()));
@@ -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 {AppRecordStreams}=await import('./app-streams.mjs');
6
- this.streams??=new AppRecordStreams(this.env,this.ctx);
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'?this.streams.open(input):this.streams.wake(input.meta);
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; }
@@ -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.3';
2
+ export const STUDIO_VERSION_TAG = '0.58.5';