@homie-rocks/studio 0.53.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
package/worker/chat-page.mjs
CHANGED
|
@@ -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'];
|
package/worker/chat-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
|
|
3
4
|
* and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
|
|
@@ -51,8 +52,8 @@ const cache = new WeakMap();
|
|
|
51
52
|
/** Every chat rule row (D1), read at most every 5 s per Worker instance; empty before migration 0007. */
|
|
52
53
|
export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
53
54
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
54
|
-
const hit = cache.get(env.DB);
|
|
55
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
55
|
+
const hit = cache.get(databaseBinding(env.DB));
|
|
56
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
56
57
|
let map = new Map();
|
|
57
58
|
try {
|
|
58
59
|
const { results } = await env.DB.prepare('SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000').all();
|
|
@@ -62,10 +63,10 @@ export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
|
62
63
|
if (rules && typeof rules === 'object') map.set(`${r.game}/${r.server ?? ''}`, { ...rules, at: Number(r.updated_at) || 0 });
|
|
63
64
|
}
|
|
64
65
|
} catch { map = new Map(); }
|
|
65
|
-
cache.set(env.DB, { at: Date.now(), map });
|
|
66
|
+
cache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
66
67
|
return map;
|
|
67
68
|
}
|
|
68
|
-
export const forgetChat = (env) => { if (env?.DB) cache.delete(env.DB); };
|
|
69
|
+
export const forgetChat = (env) => { if (env?.DB) cache.delete(databaseBinding(env.DB)); };
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* A room's chat rules: the game's game.json "chat", the owner's rules for the game, then for this server (a server's
|
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) {
|
|
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
|
-
//
|
|
56
|
-
|
|
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
|
|
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
|
|
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(()
|
|
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
|
|
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
|
|
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
|
}
|
package/worker/functions.mjs
CHANGED
|
@@ -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
|
-
|
|
10
|
-
export
|
|
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)
|
|
56
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
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) {
|