@homie-rocks/studio 0.55.0 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
@@ -0,0 +1,119 @@
1
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
2
+ // Retry transport failures in order. Queue limits still bound memory during outages.
3
+ export async function retryRelay(work, alive = () => true, wait = sleep) {
4
+ for (let attempt = 0; alive(); attempt++) {
5
+ try { return await work(); } catch (error) {
6
+ if (!alive()) return;
7
+ await wait(Math.min(5000, 100 * 2 ** Math.min(attempt, 6)));
8
+ }
9
+ }
10
+ }
11
+
12
+ /** Ordered input batches over bindings. A completed request holds no connection;
13
+ * output travels on a separate stream, never a recursive callback chain. */
14
+ export function relayChannel(ctx, deliver, {sleep: wait = sleep} = {}) {
15
+ const listeners = new Map();
16
+ let chain = Promise.resolve(), closed = false, pending = 0, pendingBytes = 0, live = null, sequence = 0;
17
+ const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
18
+ return {
19
+ accept() {},
20
+ addEventListener(name, fn) { const list = listeners.get(name) ?? []; list.push(fn); listeners.set(name, list); },
21
+ emit,
22
+ async attachLive(socket) {
23
+ socket.addEventListener('message',event=>emit('message',event));
24
+ socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket){live=null;emit('detached',{});}});
25
+ socket.addEventListener('error',()=>{if(live===socket){live=null;emit('detached',{});}});
26
+ await chain;
27
+ if(closed){socket.close(1000,'closed');return;}
28
+ if(socket.readyState!==1)return;
29
+ live=socket;
30
+ emit('drain',{});
31
+ },
32
+ isLive:()=>Boolean(live),
33
+ canSend(size) { return Boolean(live) || pendingBytes+size<=4*1024*1024; },
34
+ send(data) {
35
+ if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
36
+ if (closed) throw new Error('Relay channel closed');
37
+ const size=data.length;
38
+ if (pendingBytes+size>4*1024*1024) throw new Error('Relay channel queue limit');
39
+ pending++;pendingBytes+=size;
40
+ const id = ++sequence;
41
+ chain = chain.then(async () => {
42
+ if (closed) return;
43
+ await retryRelay(async () => {
44
+ const response = await deliver(data, id);
45
+ await response.body?.cancel();
46
+ if (!response.ok) throw new Error('Relay delivery failed: ' + response.status);
47
+ }, () => !closed, wait);
48
+ }).catch(error => { if (!closed) { closed = true; emit('error', {error}); } }).finally(() => { pending--;pendingBytes-=size;emit('drain',{}); });
49
+ ctx.waitUntil(chain);
50
+ },
51
+ close(code, reason) { if (!closed) { closed = true;live?.close(code,reason);live=null;emit('close', {code, reason}); } },
52
+ };
53
+ }
54
+
55
+ /** A Table's output is ordered on one stream while active. Closing the stream
56
+ * detaches transport, not logical players. An alarm or a player's next message
57
+ * can request another stream without sending a new welcome. */
58
+ export function relayStream(ctx, wake, {sleep: wait = sleep} = {}) {
59
+ const listeners = new Map(), encoder = new TextEncoder();
60
+ const limit = 4 * 1024 * 1024;
61
+ let controller = null, live = null, queued = [], bytes = 0, waking = false, closed = false;
62
+ const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
63
+ const request = () => {
64
+ if (waking || closed) return;
65
+ waking = true;
66
+ ctx.waitUntil(retryRelay(wake, () => !closed && waking, wait));
67
+ };
68
+ return {
69
+ accept() {}, emit,
70
+ canSend(size, data) {
71
+ const needed=data===undefined?size*3+1:encoder.encode(data+'\n').byteLength;
72
+ return !closed && (Boolean(live) || needed+(controller?limit-controller.desiredSize:bytes)<=limit);
73
+ },
74
+ addEventListener(name, fn) { const list=listeners.get(name)??[];list.push(fn);listeners.set(name,list); },
75
+ send(data) {
76
+ if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
77
+ const chunk = encoder.encode(data+'\n');
78
+ if (chunk.byteLength + (controller ? limit-controller.desiredSize : bytes) > limit) throw new Error('Relay output queue limit');
79
+ if (controller) controller.enqueue(chunk);
80
+ else { queued.push(chunk);bytes+=chunk.byteLength;request(); }
81
+ },
82
+ attachLive(socket) {
83
+ controller?.close();controller=null;live?.close(1000,'replaced');live=socket;waking=false;emit('drain',{});
84
+ for(const chunk of queued)socket.send(new TextDecoder().decode(chunk).trimEnd());queued=[];bytes=0;
85
+ socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket)live=null;});
86
+ socket.addEventListener('error',()=>{if(live===socket)live=null;});
87
+ },
88
+ open() {
89
+ controller?.close();
90
+ let owned;
91
+ return new ReadableStream({
92
+ start(next) { owned=next;controller=next;waking=false;for(const chunk of queued)next.enqueue(chunk);queued=[];bytes=0; },
93
+ pull() { emit('drain',{}); },
94
+ cancel() { if(controller===owned)controller=null; },
95
+ },{highWaterMark:limit,size:chunk=>chunk.byteLength});
96
+ },
97
+ sleep() { controller?.close();controller=null;const socket=live;live=null;socket?.close(1000,'room idle'); },
98
+ close(code,reason) {
99
+ if(closed)return;closed=true;
100
+ if(live){try{live.send(JSON.stringify([['link-close',code,reason]]));}catch{}live.close(code,reason);live=null;}
101
+ if(controller){controller.enqueue(encoder.encode(JSON.stringify([['link-close',code,reason]])+'\n'));controller.close();}
102
+ controller=null;queued=[];bytes=0;emit('close',{code,reason});
103
+ },
104
+ };
105
+ }
106
+
107
+ export async function readRelay(response, socket) {
108
+ if (!response.ok || !response.body) throw new Error('Relay stream refused');
109
+ const reader=response.body.getReader(),decoder=new TextDecoder();let pending='';
110
+ try {
111
+ for (;;) {
112
+ const {value,done}=await reader.read();if(done)break;
113
+ pending+=decoder.decode(value,{stream:true});
114
+ let end;while((end=pending.indexOf('\n'))>=0){if(end>4*1024*1024)throw new Error('Relay frame limit');const data=pending.slice(0,end);pending=pending.slice(end+1);socket.emit('message',{data});}
115
+ if(pending.length>4*1024*1024)throw new Error('Relay frame limit');
116
+ }
117
+ if(pending)throw new Error('Incomplete relay frame');
118
+ } finally { await reader.cancel().catch(()=>{}); }
119
+ }
@@ -1,3 +1,4 @@
1
+ import {LIVE_ROOMS_QUERY} from './lobby-summary.mjs';
1
2
  export const METADATA_REVISION=Symbol('metadata revision');
2
3
  export const ORIGINAL_DB=Symbol('original database');
3
4
  export const MUTATIONS=Symbol('request mutations');
@@ -15,7 +16,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
15
16
  // any bookmark returned by an earlier request without trusting stale auth.
16
17
  if(DB===binding&&typeof DB.withSession==='function')DB=DB.withSession('first-primary');
17
18
  let revision,records,rows,liveRooms;
18
- const prefetchRooms=false;
19
+ const prefetchRooms=state.summaries&&/^\/(?:api\/(?:rooms|games)|\.well-known\/homie-studio\.json)?\/?$/.test(new URL(request.url).pathname);
19
20
  const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
20
21
  const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
21
22
  const stamp=()=>revision??=(async()=>{
@@ -27,7 +28,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
27
28
  const cold=!cached;
28
29
  const statements=[query,...(cold?queries.map(sql=>DB.prepare(sql)):[])];
29
30
  if(recordQuery)statements.push(DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined)));
30
- if(prefetchRooms)statements.push(DB.prepare('SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'));
31
+ if(prefetchRooms)statements.push(DB.prepare(LIVE_ROOMS_QUERY));
31
32
  const result=await DB.batch(statements);
32
33
  if(prefetchRooms)liveRooms=result.at(-1);
33
34
  let version=result[0].results?.[0]?.value??null;
@@ -46,7 +47,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
46
47
  const prepare=(sql,args=[])=>{
47
48
  const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
48
49
  const read=async()=>{
49
- if(sql==='SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'&&prefetchRooms){await stamp();return liveRooms;}
50
+ if(sql===LIVE_ROOMS_QUERY&&prefetchRooms){await stamp();return liveRooms;}
50
51
  if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
51
52
  if(!metadataQueries.has(sql))return original().all();
52
53
  await stamp();
@@ -130,7 +130,7 @@ onChat({ client = null, conn = null }, m) {
130
130
  if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
131
131
  this.room.chatPending += 1;
132
132
  const t0 = now;
133
- Promise.resolve()
133
+ const reviewing=Promise.resolve()
134
134
  .then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
135
135
  .then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
136
136
  .then((v) => {
@@ -150,6 +150,7 @@ onChat({ client = null, conn = null }, m) {
150
150
  if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
151
151
  this.room.publishChat(rec, who.conn, n);
152
152
  });
153
+ this.room.waitUntil?.(reviewing);
153
154
  return { ok: true, pending: true };
154
155
  }
155
156
  return this.room.publishChat(rec, who.conn, n);
@@ -0,0 +1,65 @@
1
+ /** Hibernation keeps transports outside JavaScript. Only data crosses this
2
+ * boundary; callbacks, bindings and sockets are reconstructed by the Table.
3
+ */
4
+ // Only protocol identity, admission, controls and round deadlines survive here.
5
+ // World snapshots/checkpoints belong to the existing host/room save, not sessions.
6
+ const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat`.split(' '));
7
+ function urlDelta(base, value) {
8
+ const a=new URL(base),b=new URL(value),changes=[];
9
+ for(const key of new Set([...a.searchParams.keys(),...b.searchParams.keys()])) {
10
+ const x=a.searchParams.getAll(key),y=b.searchParams.getAll(key);
11
+ if(JSON.stringify(x)!==JSON.stringify(y))changes.push([key,y]);
12
+ }
13
+ return {path:b.pathname,changes};
14
+ }
15
+ function clientURL(base, delta) {
16
+ const url=new URL(base);url.pathname=delta.path;
17
+ for(const [key,values] of delta.changes){url.searchParams.delete(key);for(const value of values)url.searchParams.append(key,value);}
18
+ return url.toString();
19
+ }
20
+ import {isQa} from './stats.mjs';
21
+ const qa = agent => isQa({headers:new Headers({'user-agent':agent??''})})?'homie-house-qa':null;
22
+ import {encodeSession as encode,decodeSession as decode} from './session-codec.mjs';
23
+ const legacyDecode=value=>JSON.parse(value,(_key,item)=>item?.__roomMap?new Map(item.__roomMap):item?.__roomSet?new Set(item.__roomSet):item?.__roomNumber?item.__roomNumber*Infinity:item);
24
+ export function roomSessions(storage) {
25
+ const sql=storage.sql;let previous=new Map();
26
+ return {
27
+ read() {
28
+ if(!sql)return null;
29
+ sql.exec('CREATE TABLE IF NOT EXISTS room_sessions(key TEXT PRIMARY KEY,value TEXT NOT NULL)');
30
+ previous=new Map(sql.exec('SELECT key,value FROM room_sessions').toArray().map(row=>[row.key,row.value]));
31
+ if(!previous.has('version'))return null;
32
+ if(!['1','2','3'].includes(previous.get('version')))throw new Error('Unsupported room session snapshot');
33
+ const read=previous.get('version')==='1'?legacyDecode:decode;
34
+ const table=previous.has('table')?read(previous.get('table')):{};
35
+ if(previous.get('version')==='3')table.relayDescriptors=[...previous].filter(([key])=>key.startsWith('relay:')).map(([key,value])=>{
36
+ const relay=read(value),prefix='relay-client:'+key.slice(6)+':';
37
+ relay.clients=[...previous].filter(([key])=>key.startsWith(prefix)).map(([,value])=>{const [id,clientId,delta,ip,agent]=read(value);return [id,clientId,clientURL(relay.url,delta),ip,qa(agent)];});
38
+ return relay;
39
+ });
40
+ return {table,fields:new Map([...previous].filter(([key])=>key.startsWith('room:')).map(([key,value])=>[key.slice(5),read(value)])),clients:new Map([...previous].filter(([key])=>key.startsWith('client:')).map(([key,value])=>[key.slice(7),read(value)]))};
41
+ },
42
+ write(room,table = {}) {
43
+ if(!sql)throw new Error('Room hibernation requires SQLite storage');
44
+ const {relayDescriptors=[],...metadata}=table;
45
+ const next=new Map([['version','3'],['table',encode(metadata)]]);
46
+ relayDescriptors.forEach((relay,index)=>{
47
+ next.set('relay:'+index,encode({url:relay.url}));
48
+ for(const [id,clientId,url,ip,agent] of relay.clients)next.set('relay-client:'+index+':'+id,encode([id,clientId,urlDelta(relay.url,url),ip,qa(agent)]));
49
+ });
50
+ for(const [key,value] of Object.entries(room))if(fields.has(key)&&value!==undefined&&typeof value!=='function')next.set('room:'+key,encode(value));
51
+ for(const [id,client] of room.clients){const {conn,lastSeen,probe,readyAt,ingressRateAt,rttWindow,rttReadAt,rttToldAt,measuredRtt,...state}=client;
52
+ if(state.rates instanceof Map)state.rates=new Map([...state.rates].filter(([key])=>key!=='ping'&&key!=='ping:view'));next.set('client:'+id,encode(state));}
53
+ storage.transactionSync(()=>{
54
+ for(const [key,value] of next)if(previous.get(key)!==value)sql.exec('INSERT INTO room_sessions VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',key,value);
55
+ for(const key of previous.keys())if(!next.has(key))sql.exec('DELETE FROM room_sessions WHERE key=?',key);
56
+ });
57
+ previous=next;
58
+ },
59
+ clear() {if(sql&&previous.size){sql.exec('DELETE FROM room_sessions');previous.clear();}},
60
+ };
61
+ }
62
+ export function restoreRoomFields(room,snapshot) {
63
+ if(!snapshot)return;
64
+ for(const [key,value] of snapshot.fields)if(fields.has(key)&&Object.hasOwn(room,key)&&typeof room[key]!=='function')room[key]=value;
65
+ }
package/worker/room.mjs CHANGED
@@ -53,6 +53,10 @@ export class NetRoom {
53
53
  this.agentsOut = null;
54
54
  /** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
55
55
  this.vocab = null;
56
+ this.rawVocab = null;
57
+ this.officeDirty = false;
58
+ this.endedMatch = null;
59
+ this.snapAt = new Map();
56
60
  this.code = code;
57
61
  this.rules = rules === true;
58
62
  this.tickHz = tickHz;
@@ -268,6 +272,7 @@ export class NetRoom {
268
272
  this.lastSnap = snap;
269
273
  if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
270
274
  if (c) c.lastSnapAt = now;
275
+ this.retryBrowserSeats(c);
271
276
  this.stats.snaps += 1;
272
277
  this.snapTimes.push(now);
273
278
  while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
@@ -384,6 +389,34 @@ export class NetRoom {
384
389
  this.tellWatchers();
385
390
  }
386
391
 
392
+ /** A browser can hear joins before its world exists. Its roster acknowledges
393
+ * bodies, so re-announce missing connected seats until it acknowledges them.
394
+ * Use the existing join protocol for games bundling older netplay clients.
395
+ * Snapshot traffic retries even if the boot roster was published too early;
396
+ * retries back off from 1 to 30 seconds and stop after eight attempts.
397
+ * An exhausted peer is logged once; retries are never sent to replicas.
398
+ */
399
+ retryBrowserSeats(host) {
400
+ if (!host || this.server || this.rules || host.id !== this.hostId || !this.lastRoster) return;
401
+ const seated = new Set(this.lastRoster.filter(s => !s.bot || s.agent).flatMap(s => [s.seat, s.agent?.seat]));
402
+ const now = this.now();
403
+ for (const peer of this.live()) {
404
+ if (peer.id === host.id || peer.seat === null || peer.watch) continue;
405
+ if (seated.has(peer.seat)) { peer.seatRetry = null; continue; }
406
+ const retry = peer.seatRetry?.host === host.id ? peer.seatRetry : null;
407
+ if (retry && now - retry.at < Math.min(1000 * 2 ** (retry.tries - 1), 30000)) continue;
408
+ if (retry?.tries >= 8) {
409
+ if (!retry.exhausted) {
410
+ retry.exhausted = true;
411
+ this.log({ ev: 'seat-retry-exhausted', room: this.code, id: peer.id, seat: peer.seat, host: host.id, tries: retry.tries });
412
+ }
413
+ continue;
414
+ }
415
+ peer.seatRetry = { host: host.id, at: now, tries: (retry?.tries ?? 0) + 1 };
416
+ this.send(host, { t: 'join', peer: this.peer(peer) });
417
+ }
418
+ }
419
+
387
420
  hostCaps(c, m) {
388
421
  // The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
389
422
  if (!Array.isArray(m.caps)) return;
@@ -401,7 +434,7 @@ export class NetRoom {
401
434
  /* ------------------------------------------------------------ sockets */
402
435
 
403
436
  /** A new relay socket. Returns the two callbacks the transport must call. */
404
- attach(conn) {
437
+ attach(conn, resume = null) {
405
438
  const now = this.now();
406
439
  const c = {
407
440
  id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
@@ -422,6 +455,7 @@ export class NetRoom {
422
455
  // address, so a helper from before revisions carries it too) when the transport gives one, else the hello's.
423
456
  verWord: 'ver' in conn ? versionOf(conn.ver) : undefined, ver: null, feat: [], staleTold: false,
424
457
  };
458
+ if(resume)Object.assign(c,resume,{conn});
425
459
  this.clients.set(c.id, c);
426
460
  return {
427
461
  id: c.id,
@@ -451,18 +485,19 @@ export class NetRoom {
451
485
  * The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
452
486
  * from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
453
487
  */
454
- watch(conn) {
488
+ watch(conn, restored = null) {
455
489
  this.watchers.add(conn);
456
- this.sendText(conn, JSON.stringify(this.facts()));
490
+ if (!restored) this.sendText(conn, JSON.stringify(this.facts()));
457
491
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
458
492
  const held = this.heldNotice(conn);
459
- if (held) this.sendText(conn, JSON.stringify(held));
493
+ if (!restored && held) this.sendText(conn, JSON.stringify(held));
460
494
  // Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
461
495
  // studio holds out of the room, or a room it closed).
462
496
  // A line of this browser's or this account's own says so (`mine`), on this page's copy only: it may take it down.
463
- if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
464
- const rate = [];
497
+ if (!restored && (!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
498
+ const rate = restored?.rate ?? [];
465
499
  return {
500
+ state: {rate},
466
501
  onClose: () => this.watchers.delete(conn),
467
502
  onMessage: (text) => {
468
503
  const now = this.now();
@@ -974,7 +1009,7 @@ export class NetRoom {
974
1009
  const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
975
1010
  if (behind) c.staleTold = true;
976
1011
  this.send(c, {
977
- t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
1012
+ t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
978
1013
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
979
1014
  role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
980
1015
  round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
@@ -1345,18 +1380,19 @@ export class NetRoom {
1345
1380
  }
1346
1381
 
1347
1382
  /** Call every ~250 ms (setInterval locally, an alarm or the socket traffic on a Durable Object). */
1348
- tick() {
1383
+ tick(idle = false) {
1349
1384
  const now = this.now();
1350
1385
  // Sockets that never said hello, or went silent (a slept phone, a half-open link): close them, and a seat
1351
1386
  // becomes a bot instead of a frozen body.
1352
1387
  for (const c of [...this.clients.values()]) {
1388
+ c.lastSeen=Math.max(c.lastSeen,c.conn.lastPing?.()??0);
1353
1389
  if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
1354
1390
  // A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
1355
1391
  if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
1356
1392
  }
1357
1393
  // A host that stopped sending snapshots while others are present.
1358
1394
  const h = this.host();
1359
- if (h && this.live().length > 1) {
1395
+ if (!idle && h && this.live().length > 1) {
1360
1396
  const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
1361
1397
  if (now - since > this.stallMs) {
1362
1398
  const next = this.elect(h.id, 'host-stalled');
@@ -0,0 +1,22 @@
1
+ import {launchOf, settingsOf, accessOf, sameOrigin} from './office.mjs';
2
+ import {catalogue} from './catalogue.mjs';
3
+ /** Uses the same live session/account checks as the studio office. */
4
+ export async function isOwner(request, env) {
5
+ return (await import('./office.mjs')).isOwner(request, env);
6
+ }
7
+ /** Checks an app's declared role with its existing link, session and grant rules. */
8
+ export async function hasRole(request, env, app, role) {
9
+ const url = new URL(request.url);
10
+ const cat = await catalogue(env, url.origin);
11
+ const meta = cat.games?.find(item => item.id === app && item.kind === 'app');
12
+ if (!meta || typeof role !== 'string' || !Object.hasOwn(meta.roles ?? {}, role)) return false;
13
+ const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
14
+ if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
15
+ url.searchParams.set('role', role);
16
+ return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
17
+ }
18
+
19
+ /** Return a refusal response, or null when the browser supplied same-origin evidence. */
20
+ export function requireSameOrigin(request) {
21
+ return sameOrigin(request, new URL(request.url)) ? null : new Response('Same origin required', {status:403});
22
+ }
package/worker/routes.mjs CHANGED
@@ -1,4 +1,6 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  /** Route dispatch; large interfaces and tool runtimes load at their own entry points. */
3
+ import {customRoute} from './custom-routes.mjs';
2
4
  import { catalogue } from './catalogue.mjs';
3
5
 
4
6
  import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
@@ -40,6 +42,7 @@ export default async function route(request, env, ctx) {
40
42
  let pubP = null;
41
43
  // Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
42
44
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
45
+ const customFallback = async () => (await customRoute(request, env, ctx)) ?? fallback(request, env, url, getCat);
43
46
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
44
47
 
45
48
  // Keep tool transports and discovery on the alias; redirect browser pages only.
@@ -53,7 +56,7 @@ export default async function route(request, env, ctx) {
53
56
 
54
57
  if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
55
58
 
56
- if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
59
+ if (path === '/.well-known/jwks.json' || path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/hooks/studios/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
57
60
  const { remoteMcp } = await import('./mcp.mjs');
58
61
  return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
59
62
  }
@@ -281,7 +284,7 @@ export default async function route(request, env, ctx) {
281
284
  if (game && GAME_ID.test(game)) {
282
285
  const all = await getAll();
283
286
  const meta = (all.games ?? []).find((g) => g.id === game);
284
- if (!meta) return fallback(request, env, url, getCat);
287
+ if (!meta) return customFallback();
285
288
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
286
289
  let sub = parts.slice(1).join('/');
287
290
  // Game bundles were already public assets; only the document carries access context.
@@ -338,7 +341,7 @@ export default async function route(request, env, ctx) {
338
341
  if(!meta.room)return appRoleSession((await import('./records-page.mjs')).recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game,undefined,{request,meta,launch}):null),request,env,meta,launch);
339
342
  }
340
343
  }
341
- const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
344
+ const lobby = () => lobbyFor(env,game,/^[A-Za-z0-9_-]{16,43}$/.test(url.searchParams.get('b')??'')?url.searchParams.get('b'):'');
342
345
  if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
343
346
 
344
347
  // SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
@@ -629,7 +632,7 @@ export default async function route(request, env, ctx) {
629
632
  const cur = versionOf(meta?.netplay?.version) ?? '';
630
633
  const gv = versionOf(url.searchParams.get('gv')) ?? '';
631
634
  const stall = Number(meta?.netplay?.stallMs) || 0;
632
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
635
+ const target = `https://table/${sub}?relay=1&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
633
636
  // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
634
637
  // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
635
638
  // uncaught error with nothing on it.
@@ -663,5 +666,5 @@ export default async function route(request, env, ctx) {
663
666
  }
664
667
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
665
668
  }
666
- return fallback(request, env, url, getCat);
669
+ return customFallback();
667
670
  }
@@ -1,6 +1,8 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {studioTools,directoryOf} from './tool-registry.mjs';
2
3
  export {useTools,getStudioTools} from './tool-registry.mjs';
3
4
  import {json,finish} from './responses.mjs';
5
+ import {liveRoomSummaries} from './lobby-summary.mjs';
4
6
  /** Shared catalogue, assets, and room presentation helpers. */
5
7
  import {catalogue,builtPosts} from './catalogue.mjs';
6
8
 
@@ -138,11 +140,11 @@ async function roomsOf(env, games, { servers = null } = {}) {
138
140
  const live = {};
139
141
  // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
140
142
  const chatRows = await chatRowsOf(env);
141
- const summaries=null;
143
+ const summaries=await liveRoomSummaries(env);
142
144
  await Promise.all(games.map(async (g) => {
143
145
  let list = [];
144
146
  if(summaries)list=summaries.get(g.id)??[];
145
- else try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
147
+ else try { list = (await (await lobbyFor(env,g.id).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
146
148
  const busy = list.filter((r) => (r.players || 0) > 0);
147
149
  live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
148
150
  const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
@@ -428,7 +430,7 @@ async function weekOf(env, game) {
428
430
  const w = await playedThisWeek(env, game);
429
431
  if (!w) return null;
430
432
  let peak = 0;
431
- try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
433
+ try { peak = (await (await lobbyFor(env,game).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
432
434
  return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
433
435
  }
434
436
 
@@ -0,0 +1,24 @@
1
+ /** Explicit containers keep user checkpoint keys from colliding with type tags. */
2
+ function pack(value) {
3
+ if(value===undefined)return ['u'];
4
+ if(typeof value==='number'&&!Number.isFinite(value))return ['n',String(value)];
5
+ if(value===null||typeof value!=='object')return ['v',value];
6
+ if(value instanceof Map)return ['m',[...value].map(([key,item])=>[pack(key),pack(item)])];
7
+ if(value instanceof Set)return ['s',[...value].map(pack)];
8
+ if(Array.isArray(value))return ['a',value.map(pack)];
9
+ return ['o',Object.entries(value).filter(([,item])=>typeof item!=='function').map(([key,item])=>[key,pack(item)])];
10
+ }
11
+ function unpack([type,value]) {
12
+ switch(type) {
13
+ case 'u':return undefined;
14
+ case 'n':return Number(value);
15
+ case 'v':return value;
16
+ case 'm':return new Map(value.map(([key,item])=>[unpack(key),unpack(item)]));
17
+ case 's':return new Set(value.map(unpack));
18
+ case 'a':return value.map(unpack);
19
+ case 'o':return Object.fromEntries(value.map(([key,item])=>[key,unpack(item)]));
20
+ default:throw new Error('Invalid room session value');
21
+ }
22
+ }
23
+ export const encodeSession=value=>JSON.stringify(pack(value));
24
+ export const decodeSession=value=>unpack(JSON.parse(value));
package/worker/stats.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {writePageMetric,analyticsStatus} from './analytics.mjs';
2
3
  /**
3
4
  * STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
@@ -255,7 +256,7 @@ async function playingNow(env, games) {
255
256
  const out = {};
256
257
  await Promise.all(games.map(async (g) => {
257
258
  try {
258
- const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/now');
259
+ const res = await lobbyFor(env,g.id).fetch('https://lobby/now');
259
260
  const body = await res.json();
260
261
  out[g.id] = { players: Number(body.players) || 0, rooms: Number(body.rooms) || 0 };
261
262
  } catch { out[g.id] = { players: 0, rooms: 0 }; }
@@ -0,0 +1,71 @@
1
+ import {decode,originKey,verifyBytes} from './studios-keys.mjs';
2
+ import {oauthStorage,audit} from './mcp-store.mjs';
3
+ import {studioOrigin} from './studios-client.mjs';
4
+ const hash=async text=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(text))),b=>b.toString(16).padStart(2,'0')).join('');
5
+ const json=(data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store','pragma':'no-cache',...(status===401?{'www-authenticate':'Bearer'}:{})}});
6
+ export async function boundedBody(request,maxBytes=65536) {
7
+ const reader=request.body?.getReader();if(!reader)return '';
8
+ const chunks=[];let bytes=0;
9
+ try{while(true){const {done,value}=await reader.read();if(done)break;bytes+=value.byteLength;if(bytes>maxBytes)throw new Error('Request size exceeded');chunks.push(value);}}
10
+ catch(error){await reader.cancel();throw error;}finally{reader.releaseLock();}
11
+ const body=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){body.set(chunk,offset);offset+=chunk.length;}return new TextDecoder('utf-8',{fatal:true}).decode(body);
12
+ }
13
+ export function studioRegistration(cat,origin){
14
+ for(const rule of Object.values(cat.studio?.mcp?.studios?.inbound??{})){
15
+ try{if(studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true})===origin&&rule.enabled!==false)return rule;}catch{/* Invalid configuration is denied. */}
16
+ }
17
+ return null;
18
+ }
19
+ export async function setStudioPolicy(env,origin,allow){
20
+ origin=studioOrigin(origin,{allowPrivate:true});
21
+ await oauthStorage(env.DB).put('studio-policy:'+origin,JSON.stringify({allow:allow===true}));
22
+ }
23
+ export async function studioPolicy(env,cat,origin){
24
+ const rule=studioRegistration(cat,origin);
25
+ return rule&&(await oauthStorage(env.DB).get('studio-policy:'+origin,'json'))?.allow===true?rule:null;
26
+ }
27
+ /** RFC 7523 client authentication. Only the claimed origin's HTTPS JWKS can prove it. */
28
+ export async function studioToken(request,env,cat,options={}) {
29
+ if(request.method!=='POST')return null;
30
+ let form;try{form=new URLSearchParams(await boundedBody(request.clone(),8192));}catch{return json({error:'invalid_request'},400);}
31
+ if(form.get('grant_type')!=='client_credentials')return null;
32
+ const receiver=new URL(request.url).origin;
33
+ if(!receiver.startsWith('https:')||form.getAll('resource').length!==1||form.get('resource')!==receiver+'/mcp')return json({error:'invalid_target'},400);
34
+ if(form.has('scope')&&form.get('scope')!=='studio')return json({error:'invalid_scope'},400);
35
+ let origin,stage='assertion';
36
+ try{
37
+ for(const name of ['client_assertion','client_assertion_type','client_id'])if(form.getAll(name).length!==1)throw Error();
38
+ if(form.get('client_assertion_type')!=='urn:ietf:params:oauth:client-assertion-type:jwt-bearer')throw Error();
39
+ const parts=form.get('client_assertion').split('.');if(parts.length!==3)throw Error();
40
+ const header=decode(parts[0]),claims=decode(parts[1]);origin=claims.iss;
41
+ stage='policy';
42
+ const rule=await studioPolicy(env,cat,origin);if(!rule)throw Error();
43
+ stage='claims';
44
+ if(studioOrigin(origin,{allowPrivate:rule.allowPrivate===true})!==origin||form.get('client_id')!==origin||claims.sub!==origin||claims.aud!==receiver+'/oauth/token'||header.alg!=='EdDSA'||header.jku||header.jwk||header.crit)throw Error();
45
+ const now=Math.floor(Date.now()/1000);
46
+ if(!Number.isSafeInteger(claims.exp)||!Number.isSafeInteger(claims.iat)||claims.iat>now+30||claims.iat<now-300||claims.exp<=now||claims.exp>claims.iat+300||claims.nbf!==undefined&&(!Number.isSafeInteger(claims.nbf)||claims.nbf>now)||typeof claims.jti!=='string'||! /^[A-Za-z0-9_-]{1,128}$/.test(claims.jti))throw Error();
47
+ stage='key discovery';
48
+ const key=await originKey(env,origin,header.kid,{...options,allowPrivate:rule.allowPrivate===true});
49
+ stage='signature';
50
+ if(!await verifyBytes(key,parts[2],parts.slice(0,2).join('.')))throw Error();
51
+ stage='replay';
52
+ const used=await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) RETURNING key').bind('studio-jti:'+await hash(JSON.stringify([origin,claims.jti])),'1',claims.exp).first();if(!used)throw Error();
53
+ }catch{
54
+ await audit(env,{client:typeof origin==='string'?origin.slice(0,2048):null},'studio-auth','denied:'+stage);
55
+ return json({error:'invalid_client',error_description:'Studio authentication failed'},401);
56
+ }
57
+ const token='homie_studio_'+Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,'0')).join('');
58
+ const record={origin,resource:receiver+'/mcp',expires:Math.floor(Date.now()/1000)+300};
59
+ await oauthStorage(env.DB).put('studio-token:'+await hash(token),JSON.stringify(record),{expiration:record.expires});
60
+ return json({access_token:token,token_type:'Bearer',expires_in:300,scope:'studio'});
61
+ }
62
+ /** Live D1 policy is re-read on every dispatch; handles only separate booking ownership. */
63
+ export async function studioCaller(request,env,cat) {
64
+ const token=/^Bearer (homie_studio_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
65
+ const record=await oauthStorage(env.DB).get('studio-token:'+await hash(token),'json');
66
+ if(!record||record.expires<=Date.now()/1000||record.resource!==new URL(request.url).origin+'/mcp')return null;
67
+ const rule=await studioPolicy(env,cat,record.origin);if(!rule)return null;
68
+ let assertedHandle;try{const raw=request.headers.get('x-studio-asserted-handle');if(raw!==null){assertedHandle=decodeURIComponent(raw);if(!/^[A-Za-z0-9._@-]{1,128}$/.test(assertedHandle))return null;}}catch{return null;}
69
+ return {id:'studio:'+await hash(JSON.stringify([record.origin,assertedHandle??null])),owner:false,roles:{},client:record.origin,tools:rule.tools??[],
70
+ studio:{origin:record.origin,verified:true,method:'private_key_jwt'},...(assertedHandle===undefined?{}:{actingFor:{assertedHandle,verified:false,assertedBy:record.origin}})};
71
+ }