@homie-rocks/studio 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { REACTIONS } from './chat.mjs';
23
23
  import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
24
- import { NET_PALETTE } from './room.mjs';
24
+ import { NET_PALETTE } from './room-protocol.mjs';
25
25
 
26
26
  /** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
27
27
  export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
@@ -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
package/worker/embed.mjs CHANGED
@@ -61,7 +61,7 @@ export function playerOrigins(value) {
61
61
  const origin = 'https://' + (wildcard ? '*.' : '') + u.host;
62
62
  if (!origins.includes(origin)) origins.push(origin);
63
63
  if (origins.length === 8) break;
64
- } catch {}
64
+ } catch { /* Invalid configured origins grant no framing permission. */ }
65
65
  }
66
66
  return origins;
67
67
  }
@@ -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';
@@ -49,14 +50,14 @@ export async function subscribeFeed(env,cat,caller,uri,remove=false) {
49
50
  await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,NULL)').bind(key(caller.connection,name),JSON.stringify({connection:caller.connection,name,after:seq})).run();
50
51
  }
51
52
  }
52
- export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromName('@studio-events/'+connection));}
53
+ export function eventHub(env,connection) {const ns=env.EVENTS??env.LOBBY;return ns.get(ns.idFromName('@studio-events/'+connection));}
53
54
  export async function pushEvents(env,cat,origin) {
54
- if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
55
- // Page subscriptions, without a studio-wide subscription or delivery cap.
56
- await env.DB.prepare("DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-stream:%' AND expires<?1").bind(Date.now()).run();
55
+ if(!(env.EVENTS??env.LOBBY)||!Object.keys(eventFeeds(env)).length)return;
56
+ // Wake only active streams. Durable subscriptions without a connection replay
57
+ // their cursor when they reconnect; they never need an idle object wake.
57
58
  let after='';const seen=new Set();
58
59
  for(;;){
59
- const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key LIKE 'event-sub:%' OR key LIKE 'mcp-event-stream:%' OR key LIKE 'tools-stream:%') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
60
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE ((key >= 'mcp-event-stream:' AND key < 'mcp-event-stream;') OR (key >= 'tools-stream:' AND key < 'tools-stream;')) AND key>?1 AND (expires IS NULL OR expires>?2) ORDER BY key LIMIT 100").bind(after,Math.floor(Date.now()/1000)).all()).results;
60
61
  for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
61
62
  const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
62
63
  if(!response.ok)throw new Error('Event stream wake failed');
@@ -69,7 +70,7 @@ export async function pushEvents(env,cat,origin) {
69
70
  const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
70
71
  export class EventStreams {
71
72
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
72
- close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
73
+ close(stream){stream.cancelled=true;clearTimeout(stream.reconnect);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(error=>console.warn("MCP stream task failed:",error.message)));}}
73
74
  background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
74
75
  async fetch(request) {
75
76
  const input=await request.json();
@@ -81,7 +82,7 @@ export class EventStreams {
81
82
  if(this.stream)this.close(this.stream);
82
83
  await this.tail;
83
84
  const {connection,lastId}=input;
84
- const subscriptions=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+connection+':%').all()).results;
85
+ const subscriptions=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key >= ?1 AND key < ?2").bind('event-sub:'+connection+':','event-sub:'+connection+';').all()).results;
85
86
  let after=subscriptions.length?Math.min(...subscriptions.map(r=>JSON.parse(r.value).after)):(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq,name='';
86
87
  if(lastId){const parts=lastId.split('/');if(parts.length!==3||parts[0]!==connection||!/^\d+$/.test(parts[1]))return new Response('Invalid Last-Event-ID',{status:400});after=Number(parts[1]);name=parts[2];}
87
88
  const stream={...input,after,name,controller:null,cancelled:false,resume:null};
@@ -91,7 +92,9 @@ export class EventStreams {
91
92
  if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
92
93
  stream.toolSignature=await this.toolSignature(input.tools??[],caller);
93
94
  stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
94
- await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
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?.();
95
98
  // Replay and later wakes share one ordered pump. Backpressure must not
96
99
  // hold up the writer's request or a function handler.
97
100
  this.background(input);
@@ -99,7 +102,7 @@ export class EventStreams {
99
102
  }
100
103
  this.background(input);return new Response(null,{status:204});
101
104
  }
102
- wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
105
+ wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(error=>console.warn("MCP stream task failed:",error.message));return work;}
103
106
  async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
104
107
  async deliver(input) {
105
108
  const stream=this.stream;if(!stream)return;
@@ -112,7 +115,7 @@ export class EventStreams {
112
115
  const signature=await this.toolSignature(input.tools,caller);
113
116
  if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
114
117
  }
115
- const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
118
+ const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key >= ?1 AND key < ?2").bind('event-sub:'+caller.connection+':','event-sub:'+caller.connection+';').all()).results;
116
119
  const subscriptions=[];
117
120
  for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
118
121
  const through=(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
@@ -141,18 +144,32 @@ export class EventStreams {
141
144
  }
142
145
  }
143
146
 
147
+ const toolCatalogues=new WeakMap();
144
148
  /** Refresh active streams whenever this Worker observes a new tool catalogue. */
145
149
  export async function pushToolList(env,cat,tools,origin) {
146
150
  if(!env.LOBBY)return;
147
151
  const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
148
152
  const signature=JSON.stringify([publicShape,eventFeeds(env)]);
153
+ const binding=databaseBinding(env.DB);
154
+ if(toolCatalogues.get(binding)===signature)return;
149
155
  const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
150
- if(previous?.value===signature)return;
156
+ if(previous?.value===signature){toolCatalogues.set(binding,signature);return;}
151
157
  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
158
  let after='';
153
159
  for(;;){
154
- const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
160
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key >= 'tools-stream:' AND key < 'tools-stream;') AND key>?1 AND (expires IS NULL OR expires>?2) ORDER BY key LIMIT 100").bind(after,Math.floor(Date.now()/1000)).all()).results;
155
161
  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
162
  if(rows.length<100)break;after=rows.at(-1).key;
157
163
  }
164
+ toolCatalogues.set(binding,signature);
165
+ }
166
+
167
+ /** Bounded expiry work belongs to cron, never to each mutation or page. */
168
+ export async function pruneMcpKeys(env) {
169
+ if(!env.DB)return;
170
+ try { await env.DB.prepare('DELETE FROM mcp_kv WHERE key IN (SELECT key FROM mcp_kv WHERE expires IS NOT NULL AND expires<?1 ORDER BY expires LIMIT 1000)').bind(Math.floor(Date.now()/1000)).run(); }
171
+ catch(error) {
172
+ // Older embedded shop workers have no MCP schema to prune.
173
+ if(!/no such table: mcp_kv/i.test(String(error?.message)))throw error;
174
+ }
158
175
  }
@@ -0,0 +1,4 @@
1
+ // Studio declarations register synchronously; their implementation loads on demand.
2
+ let load=async()=>[];
3
+ export function useFunctions(loader) {load=loader;}
4
+ export const loadFunctions=()=>load();
@@ -6,8 +6,8 @@ import {feedSubscriptions,pushEvents} from './event-resources.mjs';
6
6
  import { services } from './mcp-tools.mjs';
7
7
  import { isOwner, sameOrigin } from './office.mjs';
8
8
  import { esc } from './site.mjs';
9
- let load = async () => [];
10
- export function useFunctions(loader) { load = loader; }
9
+ import {loadFunctions as load} from './function-registry.mjs';
10
+ export {useFunctions} from './function-registry.mjs';
11
11
  export async function hasFunctionType(type,env={}) { return [...await load(),...feedSubscriptions(env)].some(fn=>fn.event===type); }
12
12
  export async function syncFunctions(env, definitions = null, now = Date.now()) {
13
13
  definitions ??= await load();
@@ -52,8 +52,10 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
52
52
  if(typeof cat==='function')cat=await cat();
53
53
  definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions),env.STUDIO_EVENT_FEEDS?await eventHookSubscriptions(env,cat,{...webhookOptions,now}):[]);
54
54
  // An AI notification must not wait behind a slow function handler.
55
- const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
56
- const notifications=notify();
55
+ const notify=()=>pushEvents(env,cat,origin);
56
+ // A final pass retries an early failed wake. If that also fails the queue
57
+ // consumer retries the batch; it must never acknowledge a lost notification.
58
+ const notifications=notify().catch(error=>console.warn('Early event wake failed; retrying after functions',error));
57
59
  let more;
58
60
  do {
59
61
  more=false;
@@ -151,7 +153,7 @@ export async function functionRuns(env,{name,errors=false,before}={}) {
151
153
  WHERE (?1 IS NULL OR function=?1) AND (?2=0 OR outcome IN ('failed','interrupted')) AND (?3 IS NULL OR seq<?3)
152
154
  ORDER BY seq DESC LIMIT 101`).bind(name??null,errors?1:0,before??null).all()).results;
153
155
  const page=rows.slice(0,100),definitions=await load();
154
- let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch {}
156
+ let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch(error) {console.warn('Invalid STUDIO_FUNCTION_CRONS:',error.message);}
155
157
  const schedules=scheduleStatus(definitions.filter(f=>!name||f.name===name),crons);
156
158
  const tick=await env.DB.prepare("SELECT value FROM meta WHERE key='function-last-scheduled'").first();
157
159
  return {runs:page.map(r=>({...r,started:new Date(r.started).toISOString(),finished:r.finished===null?null:new Date(r.finished).toISOString()})),next:rows.length>100?page.at(-1).seq:null,schedules,crons,lastScheduled:tick?JSON.parse(tick.value):null,triggerEvidence:'Generated deployment configuration; lastScheduled proves an observed tick. Provider dashboard changes are not queried.'};
@@ -0,0 +1,61 @@
1
+ /** Admission routing without loading transport encoding or crowd simulation. */
2
+ /** Players a Gate holds in a room too big for its Table to hold directly.
3
+ *
4
+ * Two trials on Cloudflare set it. A Gate of 60 dense players that selected and encoded each one's view from
5
+ * scratch (0.45.4) could not keep 20 frames a second; the same work now costs a third as much. But twenty to a
6
+ * Gate (the first 0.46.2 build) made a room of 600 fifty channels, and the room spent its time on messages, not
7
+ * rules: what the room's one thread can bear is counted in messages, and a channel is two a tick. So a Gate is
8
+ * as full as it can serve, and a room opens only the Gates its players fill.
9
+ * A studio chooses its room's size and nothing else. */
10
+ export const GATE_PLAYERS = 50;
11
+ /** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
12
+ export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
13
+ export function roomLayout(seats, per = GATE_PLAYERS) {
14
+ // A room of up to 64 is its Table alone, as it has been since 0.45.0.
15
+ const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
16
+ return { gates, concentrators: 0 };
17
+ }
18
+ /** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
19
+ function hashedGate(key, gates) {
20
+ let hash = 2166136261;
21
+ for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
22
+ return hash % gates;
23
+ }
24
+ /**
25
+ * Which Gate takes the next player: the lowest one with room. A room of 50 players in a 1,000-seat game is one
26
+ * Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
27
+ * Gate's players now and `promised` those sent to it who have not arrived yet.
28
+ */
29
+ export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
30
+ let least = 0, fewest = Infinity;
31
+ for (let i = 0; i < gates; i++) {
32
+ const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
33
+ if (n < per) return i;
34
+ if (n < fewest) { fewest = n; least = i; }
35
+ }
36
+ return least;
37
+ }
38
+ export function gateFor(env, game, room, seats, key) {
39
+ const layout = roomLayout(seats, gatePlayers(env));
40
+ // One logical Gate is embedded in the Table for a small room. Same relay,
41
+ // admission and delivery protocol; no extra Durable Object or seat clamp.
42
+ const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
43
+ if (layout.gates === 1) return table();
44
+ return { async fetch(request) {
45
+ const url = new URL(request.url);
46
+ // The room says which Gate has room (one short question a join; a join is the room's work anyway). If it
47
+ // cannot be asked in time the key's hash chooses, as before.
48
+ let index = null;
49
+ try {
50
+ const asked = await Promise.race([table().fetch(`https://table/__gate?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${seats}&gates=${layout.gates}`), new Promise(done => setTimeout(() => done(null), 2000))]);
51
+ const said = asked?.ok ? await asked.json() : null;
52
+ if (Number.isInteger(said?.gate) && said.gate >= 0 && said.gate < layout.gates) index = said.gate;
53
+ } catch { /* the hash chooses */ }
54
+ index ??= hashedGate(key, layout.gates);
55
+ url.searchParams.set('gate', String(index));
56
+ url.searchParams.set('gates', String(layout.gates));
57
+ return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
58
+ } };
59
+ }
60
+
61
+ export function diagnosticsOf(env, game, room) { return env.GATE.get(env.GATE.idFromName(`${game}/${room}/diagnostics`)); }
package/worker/gate.mjs CHANGED
@@ -27,64 +27,8 @@ function parseFrame(event) {
27
27
  * sockets: clients resume with their existing seat tokens and a fresh keyframe.
28
28
  * Inputs and commands are batched together, never overwritten or reordered.
29
29
  */
30
- /** Players a Gate holds in a room too big for its Table to hold directly.
31
- *
32
- * Two trials on Cloudflare set it. A Gate of 60 dense players that selected and encoded each one's view from
33
- * scratch (0.45.4) could not keep 20 frames a second; the same work now costs a third as much. But twenty to a
34
- * Gate (the first 0.46.2 build) made a room of 600 fifty channels, and the room spent its time on messages, not
35
- * rules: what the room's one thread can bear is counted in messages, and a channel is two a tick. So a Gate is
36
- * as full as it can serve, and a room opens only the Gates its players fill.
37
- * A studio chooses its room's size and nothing else. */
38
- export const GATE_PLAYERS = 50;
39
- /** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
40
- export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
41
- export function roomLayout(seats, per = GATE_PLAYERS) {
42
- // A room of up to 64 is its Table alone, as it has been since 0.45.0.
43
- const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
44
- return { gates, concentrators: 0 };
45
- }
46
- /** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
47
- function hashedGate(key, gates) {
48
- let hash = 2166136261;
49
- for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
50
- return hash % gates;
51
- }
52
- /**
53
- * Which Gate takes the next player: the lowest one with room. A room of 50 players in a 1,000-seat game is one
54
- * Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
55
- * Gate's players now and `promised` those sent to it who have not arrived yet.
56
- */
57
- export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
58
- let least = 0, fewest = Infinity;
59
- for (let i = 0; i < gates; i++) {
60
- const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
61
- if (n < per) return i;
62
- if (n < fewest) { fewest = n; least = i; }
63
- }
64
- return least;
65
- }
66
- export function gateFor(env, game, room, seats, key) {
67
- const layout = roomLayout(seats, gatePlayers(env));
68
- // One logical Gate is embedded in the Table for a small room. Same relay,
69
- // admission and delivery protocol; no extra Durable Object or seat clamp.
70
- const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
71
- if (layout.gates === 1) return table();
72
- return { async fetch(request) {
73
- const url = new URL(request.url);
74
- // The room says which Gate has room (one short question a join; a join is the room's work anyway). If it
75
- // cannot be asked in time the key's hash chooses, as before.
76
- let index = null;
77
- try {
78
- const asked = await Promise.race([table().fetch(`https://table/__gate?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${seats}&gates=${layout.gates}`), new Promise(done => setTimeout(() => done(null), 2000))]);
79
- const said = asked?.ok ? await asked.json() : null;
80
- if (Number.isInteger(said?.gate) && said.gate >= 0 && said.gate < layout.gates) index = said.gate;
81
- } catch { /* the hash chooses */ }
82
- index ??= hashedGate(key, layout.gates);
83
- url.searchParams.set('gate', String(index));
84
- url.searchParams.set('gates', String(layout.gates));
85
- return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
86
- } };
87
- }
30
+ export {GATE_PLAYERS,gatePlayers,roomLayout,nextGate,gateFor,diagnosticsOf} from './gate-routing.mjs';
31
+ import {diagnosticsOf} from './gate-routing.mjs';
88
32
 
89
33
  export function* expandBatch(rows) {
90
34
  for (const row of rows) {
@@ -99,7 +43,7 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
99
43
  const timing = stageTimings();
100
44
  const stats = { frames: 0, rows: 0, bytes: 0, peakQueueBytes: 0, coalesced: 0, ackMs: 0, maxAckMs: 0, rttMs: 0 };
101
45
  const close = () => { closed = true; queue = []; views.clear(); strings.clear(); pending.clear(); inFlightBytes = 0; bytes = 0; if (timer !== null) clearTimer(timer); timer = null; };
102
- const fail = (reason) => { close(); try { socket.close(1011, reason); } catch {} };
46
+ const fail = (reason) => { close(); try { socket.close(1011, reason); } catch { /* The peer already closed; local shutdown still runs. */ } };
103
47
  const schedule = () => { if (!closed && (receipt || pending.size < maxFrames && !blockedByBytes && (queue.length || views.size)) && timer === null) timer = setTimer(flush, delay > 1 ? Math.max(0, lastSentAt + delay - now()) : delay); };
104
48
  const flush = () => {
105
49
  if (timer !== null) clearTimer(timer); timer = null;
@@ -279,7 +223,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
279
223
  }
280
224
  out.consumed(rows);
281
225
  if (admissions.length && !attaching) { if (admissionTimer !== null) clearTimeout(admissionTimer); pump(); }
282
- }).catch(error => { console.log(JSON.stringify({ ev: 'room-link-failed', stage: 'receive', error: String(error?.message ?? error).slice(0,160) })); shutdown(); try { socket.close(1011, 'room link failed'); } catch {} });
226
+ }).catch(error => { console.log(JSON.stringify({ ev: 'room-link-failed', stage: 'receive', error: String(error?.message ?? error).slice(0,160) })); shutdown(); try { socket.close(1011, 'room link failed'); } catch { /* The peer already closed; local shutdown still runs. */ } });
283
227
  });
284
228
  socket.addEventListener('close', shutdown);
285
229
  socket.addEventListener('error', shutdown);
@@ -385,10 +329,10 @@ export class Gate {
385
329
  console.log(JSON.stringify({ ev: 'room-link-lost', room: name, gate: url.searchParams.get('gate'), layer: 'gate', code: event.code ?? null, reason: event.reason ?? null, error: event.error?.message ?? null, clients: this.clients.size, transport: link.facts() }));
386
330
  this.link = null; this.worlds = null; link.close();
387
331
  for (const timer of this.admissions.values()) clearTimeout(timer); this.admissions.clear();
388
- for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch {} }
332
+ for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch { /* The peer already closed; local shutdown still runs. */ } }
389
333
  this.clients.clear();
390
334
  };
391
- const drop = (id, client) => { this.clients.delete(id); link.send(['close', id]); try { client.close(1011, 'client connection ended'); } catch {} };
335
+ const drop = (id, client) => { this.clients.delete(id); link.send(['close', id]); try { client.close(1011, 'client connection ended'); } catch { /* The peer already closed; local shutdown still runs. */ } };
392
336
  socket.addEventListener('message', event => {
393
337
  if (typeof event.data !== 'string') return;
394
338
  try {
@@ -400,12 +344,12 @@ export class Gate {
400
344
  if (op === 'seat') { if (client) { client.seat = value; client.subscribed = true; } }
401
345
  else if (op === 'view') { if (client) this.deliverOne(id, client, value, reason, settings, rate, drop); }
402
346
  else if (op === 'data') { try { client?.send(value); } catch { if (client) drop(id, client); } }
403
- else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch {} }
347
+ else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch { /* The peer already closed; local shutdown still runs. */ } }
404
348
  }
405
349
  }
406
350
  link.consumed(rows);
407
351
  if (hz && Date.now() - this.metricsAt >= 1000) this.publishMetrics(url, link);
408
- } catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
352
+ } catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch { /* The peer already closed; local shutdown still runs. */ } }
409
353
  });
410
354
  socket.addEventListener('close', lost); socket.addEventListener('error', lost);
411
355
  // A Gate nobody is on gives its channel back after ten seconds: an open channel keeps the room and this Gate in
@@ -470,7 +414,7 @@ export class Gate {
470
414
  let opened = false;
471
415
  const deadline = setTimeout(() => {
472
416
  this.admissions.delete(id);
473
- if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch {} }
417
+ if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch { /* The peer already closed; local shutdown still runs. */ } }
474
418
  }, 5000);
475
419
  deadline?.unref?.(); this.admissions.set(id, deadline);
476
420
  server.addEventListener('message', event => {
@@ -481,7 +425,7 @@ export class Gate {
481
425
  if (event.data.length > 128 * 1024) {
482
426
  clearTimeout(deadline); this.admissions.delete(id); this.clients.delete(id);
483
427
  if (opened) link.send(['close', id]);
484
- try { server.close(1009, 'message too large'); } catch {}
428
+ try { server.close(1009, 'message too large'); } catch { /* The peer already closed; local shutdown still runs. */ }
485
429
  return;
486
430
  }
487
431
  if (!opened) {
@@ -545,7 +489,7 @@ export class Gate {
545
489
  this.metricsPending = true;
546
490
  const work = diagnosticsOf(this.env, url.searchParams.get('game'), url.searchParams.get('room')).fetch('https://diagnostics/__diagnostics', {
547
491
  method: 'POST', body: JSON.stringify({ kind: 'gate', id: `gate/${url.searchParams.get('gate')}`, at: now, ...facts }),
548
- }).then(r => r.body?.cancel?.()).catch(() => {}).finally(() => { this.metricsPending = false; });
492
+ }).then(r => r.body?.cancel?.()).catch(error => console.warn("Gate diagnostic publication failed:",error.message)).finally(() => { this.metricsPending = false; });
549
493
  this.ctx.waitUntil?.(work);
550
494
  }
551
495
  /** The room's diagnostics, kept by an object that is neither the room nor a
@@ -576,6 +520,6 @@ export class Gate {
576
520
  }
577
521
  }
578
522
  /** The object that keeps one room's diagnostics (a Gate by class, never one with players). */
579
- export function diagnosticsOf(env, game, room) { return env.GATE.get(env.GATE.idFromName(`${game}/${room}/diagnostics`)); }
523
+
580
524
  /** Kept so a studio deployed with 0.45.0's Concentrator binding still loads; nothing opens one. */
581
525
  export class Concentrator extends Gate {}
@@ -0,0 +1,11 @@
1
+ /** Declarations stay cheap to inspect on HTTP routes; the host engine loads in Table. */
2
+ export const games=new Map();
3
+ export const compiled=new Map();
4
+ export function hostRules(table) {
5
+ games.clear();compiled.clear();
6
+ if(!table||typeof table!=='object')return 0;
7
+ for(const [id,entry] of Object.entries(table))if(entry&&typeof entry==='object'&&entry.rules)games.set(id,entry);
8
+ return games.size;
9
+ }
10
+ export const hostedGame=id=>games.has(id);
11
+ export const hostedBuild=id=>games.get(id);
package/worker/hosted.mjs CHANGED
@@ -16,23 +16,9 @@
16
16
  import { createHost } from '../rules/host.ts';
17
17
  import { compileMap, compileRules } from '../rules/rules.ts';
18
18
 
19
- /** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
20
- const games = new Map();
21
- const compiled = new Map();
22
- const readiness = new WeakMap();
23
-
24
- /** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
25
- export function hostRules(table) {
26
- games.clear();
27
- compiled.clear();
28
- if (!table || typeof table !== 'object') return 0;
29
- for (const [id, entry] of Object.entries(table)) if (entry && typeof entry === 'object' && entry.rules) games.set(id, entry);
30
- return games.size;
31
- }
32
-
33
- /** Whether this Worker holds rules for a game. */
34
- export const hostedGame = (id) => games.has(id);
35
- export const hostedBuild = (id) => games.get(id);
19
+ import {games,compiled} from './hosted-registry.mjs';
20
+ export {hostRules,hostedGame,hostedBuild} from './hosted-registry.mjs';
21
+ const readiness=new WeakMap();
36
22
 
37
23
  /** Optional engines load at room start. Ordinary games keep their synchronous startup path. */
38
24
  export function prepareHost(id) {