@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Generated standard entries load room engines only when their object is used.
|
|
2
|
+
* Custom subclasses retain their own exports and do not use this adapter. */
|
|
3
|
+
export class Table {
|
|
4
|
+
constructor(ctx,env) {
|
|
5
|
+
this.ready=ctx.blockConcurrencyWhile(async()=>{this.room=new (await import('./table.mjs')).Table(ctx,env);});
|
|
6
|
+
}
|
|
7
|
+
async fetch(request) {await this.ready;return this.room.fetch(request);}
|
|
8
|
+
async alarm() {await this.ready;return this.room.alarm();}
|
|
9
|
+
async webSocketMessage(...args) {await this.ready;return this.room.webSocketMessage(...args);}
|
|
10
|
+
async webSocketClose(...args) {await this.ready;return this.room.webSocketClose(...args);}
|
|
11
|
+
async webSocketError(...args) {await this.ready;return this.room.webSocketError(...args);}
|
|
12
|
+
}
|
|
13
|
+
export class Lobby {
|
|
14
|
+
constructor(ctx,env) {
|
|
15
|
+
this.ready=ctx.blockConcurrencyWhile(async()=>{this.lobby=new (await import('./lobby.mjs')).Lobby(ctx,env);});
|
|
16
|
+
}
|
|
17
|
+
async fetch(request) {await this.ready;return this.lobby.fetch(request);}
|
|
18
|
+
async alarm() {await this.ready;return this.lobby.alarm();}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Gate {
|
|
22
|
+
constructor(ctx,env) {
|
|
23
|
+
this.ready=ctx.blockConcurrencyWhile(async()=>{this.gate=new (await import('./gate.mjs')).Gate(ctx,env);});
|
|
24
|
+
}
|
|
25
|
+
async fetch(request) {await this.ready;return this.gate.fetch(request);}
|
|
26
|
+
}
|
|
27
|
+
export class Concentrator extends Gate {}
|
|
28
|
+
|
|
29
|
+
export class AppRecordHub {
|
|
30
|
+
constructor(ctx,env) {
|
|
31
|
+
this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).AppRecordHub(ctx,env);});
|
|
32
|
+
}
|
|
33
|
+
async fetch(request) {await this.ready;return this.hub.fetch(request);}
|
|
34
|
+
}
|
|
35
|
+
export class EventHub {
|
|
36
|
+
constructor(ctx,env) {
|
|
37
|
+
this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).EventHub(ctx,env);});
|
|
38
|
+
}
|
|
39
|
+
async fetch(request) {await this.ready;return this.hub.fetch(request);}
|
|
40
|
+
}
|
package/worker/lobby.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { errorLine, versionOf } from './room-protocol.mjs';
|
|
2
|
+
import { ROOM_ID } from './page-policy.mjs';
|
|
3
|
+
import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
|
|
4
|
+
import { seatCount } from './seats.mjs';
|
|
5
|
+
import { counter, today } from './stats.mjs';
|
|
6
|
+
|
|
7
|
+
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
8
|
+
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const LOBBY_APP_VERSIONS = 8;
|
|
12
|
+
|
|
13
|
+
export const LOBBY_ROOMS_MAX = 512;
|
|
14
|
+
|
|
15
|
+
export class Lobby {
|
|
16
|
+
constructor(ctx, env) {
|
|
17
|
+
this.ctx = ctx;
|
|
18
|
+
this.env = env;
|
|
19
|
+
this.rooms = new Map();
|
|
20
|
+
/** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
|
|
21
|
+
this.next = { public: 1 };
|
|
22
|
+
/** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
|
|
23
|
+
this.live = new Map();
|
|
24
|
+
this.peak = { day: null, players: 0, room: 0 };
|
|
25
|
+
/** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
|
|
26
|
+
this.seen = new Map();
|
|
27
|
+
this.closed = new Map();
|
|
28
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
29
|
+
const saved = await ctx.storage.get('lobby');
|
|
30
|
+
if (saved) {
|
|
31
|
+
this.current = saved.current ?? null;
|
|
32
|
+
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
33
|
+
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
|
|
34
|
+
for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
|
|
41
|
+
* reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
|
|
42
|
+
*/
|
|
43
|
+
notePeak(room, players, game) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
|
|
46
|
+
for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
|
|
47
|
+
const day = today();
|
|
48
|
+
if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
|
|
49
|
+
let all = 0;
|
|
50
|
+
for (const r of this.live.values()) all += r.players;
|
|
51
|
+
const writes = [];
|
|
52
|
+
if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
|
|
53
|
+
if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
|
|
54
|
+
if (game) this.game = game;
|
|
55
|
+
const list = writes.filter(Boolean);
|
|
56
|
+
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
save() { this.ctx.storage.put('lobby', { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
|
|
60
|
+
|
|
61
|
+
prune(now) {
|
|
62
|
+
for (const [name, r] of this.rooms) {
|
|
63
|
+
r.pending = r.pending.filter((t) => now - t < 20_000);
|
|
64
|
+
r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
|
|
65
|
+
if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
|
|
70
|
+
async fetch(request) {
|
|
71
|
+
if(new URL(request.url).pathname==='/__app_records') {
|
|
72
|
+
const {AppRecordStreams}=await import('./app-streams.mjs');
|
|
73
|
+
this.appStreams??=new AppRecordStreams(this.env,this.ctx);
|
|
74
|
+
const input=await request.json();
|
|
75
|
+
return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
|
|
76
|
+
}
|
|
77
|
+
if(new URL(request.url).pathname==='/__studio_events') {
|
|
78
|
+
const {EventStreams}=await import('./event-resources.mjs');
|
|
79
|
+
this.eventStreams??=new EventStreams(this.env,this.ctx);
|
|
80
|
+
return this.eventStreams.fetch(request);
|
|
81
|
+
}
|
|
82
|
+
try { return await this.answer(request); } catch (error) {
|
|
83
|
+
let path = '';
|
|
84
|
+
try { path = new URL(request.url).pathname; } catch { /* not an address */ }
|
|
85
|
+
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
|
|
86
|
+
return json({ ok: false, error: 'lobby' }, 500);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async answer(request) {
|
|
91
|
+
const url = new URL(request.url);
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
this.prune(now);
|
|
94
|
+
// REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
|
|
95
|
+
// the build of a game that names none ('').
|
|
96
|
+
const ver = versionOf(url.searchParams.get('ver')) ?? '';
|
|
97
|
+
if (url.pathname === '/join') {
|
|
98
|
+
// A server build can run only its current rules. Persisted browser rooms
|
|
99
|
+
// (including pre-kind records) and old server revisions must not consume
|
|
100
|
+
// the pool or become the fallback of a server with roomsMax=1.
|
|
101
|
+
const host = url.searchParams.get('host');
|
|
102
|
+
if ((host === 'server' || host === 'browser') && (this.current?.host !== host || this.current?.ver !== ver)) {
|
|
103
|
+
this.current = { host, ver };
|
|
104
|
+
for (const [name, r] of this.rooms) if (
|
|
105
|
+
host === 'server' ? (r.host ?? 'browser') !== host || (r.ver ?? '') !== ver : r.host === 'server'
|
|
106
|
+
) { this.rooms.delete(name); this.seen.delete(name); this.live.delete(name); }
|
|
107
|
+
this.save();
|
|
108
|
+
}
|
|
109
|
+
const max = seatCount(url.searchParams.get('max'));
|
|
110
|
+
const sid = url.searchParams.get('server') ?? 'public';
|
|
111
|
+
const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
|
|
112
|
+
const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
|
|
113
|
+
// A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
|
|
114
|
+
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
115
|
+
for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
|
|
116
|
+
// Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
|
|
117
|
+
const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
|
|
118
|
+
const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
|
|
119
|
+
const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
|
|
120
|
+
// A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
|
|
121
|
+
// builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
|
|
122
|
+
const app = url.searchParams.get('app') === '1';
|
|
123
|
+
if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
|
|
124
|
+
return json({ ok: false, room: null, error: 'versions', message: 'Too many versions of this game are being played at once: play offline for now.' }, 503);
|
|
125
|
+
}
|
|
126
|
+
if (url.searchParams.get('agent') === '1') {
|
|
127
|
+
// An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
|
|
128
|
+
const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
|
|
129
|
+
let best = null;
|
|
130
|
+
for (const r of pool) {
|
|
131
|
+
const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
|
|
132
|
+
if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
|
|
133
|
+
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
134
|
+
}
|
|
135
|
+
if (!best) return json({ room: null, players: 0, max });
|
|
136
|
+
(best.agentPending ??= []).push(now);
|
|
137
|
+
return json({ room: best.name, players: best.players, max });
|
|
138
|
+
}
|
|
139
|
+
let best = null;
|
|
140
|
+
for (const r of pool) {
|
|
141
|
+
const fill = r.players + r.pending.length;
|
|
142
|
+
if (fill >= max) continue;
|
|
143
|
+
if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
|
|
144
|
+
}
|
|
145
|
+
let room = best?.r;
|
|
146
|
+
let full = false;
|
|
147
|
+
const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
|
|
148
|
+
if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
|
|
149
|
+
// Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
|
|
150
|
+
// visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
|
|
151
|
+
// build is live, and the visitor is let in when they have it). Never a room past the server's own number.
|
|
152
|
+
const from = pool.length ? pool : ofServer;
|
|
153
|
+
if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
|
|
154
|
+
room = fullest(from);
|
|
155
|
+
full = true;
|
|
156
|
+
}
|
|
157
|
+
if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
|
|
158
|
+
// The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
|
|
159
|
+
// first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
|
|
160
|
+
const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
|
|
161
|
+
for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
|
|
162
|
+
if (this.rooms.size >= LOBBY_ROOMS_MAX) {
|
|
163
|
+
if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
|
|
164
|
+
room = fullest(pool);
|
|
165
|
+
full = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!room) {
|
|
169
|
+
let n = this.next[server] ?? 1;
|
|
170
|
+
while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
|
|
171
|
+
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
|
|
172
|
+
this.next[server] = n + 1;
|
|
173
|
+
this.rooms.set(room.name, room);
|
|
174
|
+
}
|
|
175
|
+
room.pending.push(now);
|
|
176
|
+
this.save();
|
|
177
|
+
return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
|
|
178
|
+
}
|
|
179
|
+
if (url.pathname === '/report' && request.method === 'POST') {
|
|
180
|
+
const body = await request.json().catch(() => ({}));
|
|
181
|
+
if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
|
|
182
|
+
if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
|
|
183
|
+
// The owner closed this room: matched with nobody until then (and forgotten as a public room).
|
|
184
|
+
if (Number.isFinite(body.closed)) {
|
|
185
|
+
if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
|
|
186
|
+
this.seen.delete(body.room);
|
|
187
|
+
this.save();
|
|
188
|
+
return json({ ok: true, closed: body.closed > now });
|
|
189
|
+
}
|
|
190
|
+
// A retiring isolate may send its last report after the new pool was
|
|
191
|
+
// selected. It cannot resurrect an incompatible public room.
|
|
192
|
+
if (pooledRoom(body.room) && this.current && (this.current.host === 'server'
|
|
193
|
+
? (body.host ?? 'browser') !== 'server' || (body.ver ?? '') !== this.current.ver
|
|
194
|
+
: body.host === 'server')) return json({ ok: true, retired: true });
|
|
195
|
+
const seated = Math.max(0, Math.floor(Number(body.players) || 0));
|
|
196
|
+
const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
|
|
197
|
+
const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
|
|
198
|
+
if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), agents, ai, at: now });
|
|
199
|
+
else this.seen.delete(body.room);
|
|
200
|
+
if (this.closed.has(body.room)) return json({ ok: true, closed: true });
|
|
201
|
+
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
202
|
+
// Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
|
|
203
|
+
if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
|
|
204
|
+
const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, ver: '', pending: [], agentPending: [] };
|
|
205
|
+
const grew = Math.max(0, seated - r.players);
|
|
206
|
+
// The room says which build it runs (it changes only when nobody on the old one is left).
|
|
207
|
+
const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
|
|
208
|
+
const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
|
|
209
|
+
r.ver = rver;
|
|
210
|
+
if (body.host === 'server' || body.host === 'browser') r.host = body.host;
|
|
211
|
+
r.pending.splice(0, grew);
|
|
212
|
+
if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
|
|
213
|
+
r.players = seated;
|
|
214
|
+
r.agents = agents;
|
|
215
|
+
r.ai = ai;
|
|
216
|
+
r.at = now;
|
|
217
|
+
this.rooms.set(r.name, r);
|
|
218
|
+
if (changed) this.save(); // the minute's "still here" report writes nothing
|
|
219
|
+
return json({ ok: true });
|
|
220
|
+
}
|
|
221
|
+
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents: agents ?? 0, ai: ai ?? 0, server: server ?? roomServer(name), at })) });
|
|
222
|
+
if (url.pathname === '/busiest') {
|
|
223
|
+
// The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
|
|
224
|
+
// no seat. A closed room, one named in `not`, or one of a pool not in `pools` (a server with a door) is never it.
|
|
225
|
+
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
226
|
+
const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
|
|
227
|
+
let best = null;
|
|
228
|
+
for (const r of this.rooms.values()) {
|
|
229
|
+
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
|
|
230
|
+
// A watcher's page runs the live build: a room still on an older one would turn it away.
|
|
231
|
+
if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
|
|
232
|
+
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
233
|
+
}
|
|
234
|
+
return json({ room: best?.name ?? null, players: best?.players ?? 0 });
|
|
235
|
+
}
|
|
236
|
+
if (url.pathname === '/office') {
|
|
237
|
+
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
238
|
+
for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
|
|
239
|
+
const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
|
|
240
|
+
for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
|
|
241
|
+
for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
|
|
242
|
+
return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
|
|
243
|
+
}
|
|
244
|
+
if (url.pathname === '/now') {
|
|
245
|
+
let players = 0;
|
|
246
|
+
for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
|
|
247
|
+
return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
|
|
248
|
+
}
|
|
249
|
+
return json({ error: 'not found' }, 404);
|
|
250
|
+
}
|
|
251
|
+
}
|
package/worker/lounge.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { encodeFacts } from './agents.mjs';
|
|
|
28
28
|
import { PUBLIC_SERVER, policyOf } from './servers.mjs';
|
|
29
29
|
import { players } from './players.mjs';
|
|
30
30
|
import { signControl } from './office.mjs';
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
33
33
|
|
|
34
34
|
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
@@ -262,14 +262,14 @@ export async function loungeRoutes(request, env, ctx, url, { catalogueOf, roomsO
|
|
|
262
262
|
const cat = await catalogueOf();
|
|
263
263
|
const L = loungeOf(cat);
|
|
264
264
|
if (!L) return null;
|
|
265
|
-
if (path === '/_homie/lounge.js') return new Response(LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
265
|
+
if (path === '/_homie/lounge.js') return new Response((await import('./lounge-page.mjs')).LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
266
266
|
if (path === '/lounge' && read) return Response.redirect(`${url.origin}/lounge/${url.search}`, 301);
|
|
267
267
|
if (!env.TABLE || !env.DB) return path.startsWith('/lounge/api/') ? json({ ok: false, error: 'no-rooms', message: 'This studio has no rooms or database bound yet: `npm run deploy` sets them up.' }, 503) : null;
|
|
268
268
|
const local = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url.origin);
|
|
269
269
|
|
|
270
270
|
if (path === '/lounge/' && read) {
|
|
271
271
|
const [who, rules, nights] = await Promise.all([whoOf(request, env), loungeRules(env, cat), nightsOf(env)]);
|
|
272
|
-
return loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
|
|
272
|
+
return (await import('./lounge-page.mjs')).loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
if (path === '/lounge/__watch') {
|
|
@@ -4,7 +4,7 @@ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseF
|
|
|
4
4
|
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
5
|
export class McpEventStreams {
|
|
6
6
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
-
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(error=>console.warn("MCP stream cleanup deferred to expiry:",error.message)));}
|
|
8
8
|
async write(stream,value,authorize){
|
|
9
9
|
if(stream.closed)return false;
|
|
10
10
|
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
@@ -19,7 +19,7 @@ export class McpEventStreams {
|
|
|
19
19
|
const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
|
|
20
20
|
const start=await eventStart(this.env,input.params);
|
|
21
21
|
const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
|
|
22
|
-
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+
|
|
22
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Math.floor(Date.now()/1000)+60).run();
|
|
23
23
|
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
24
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
25
|
this.streams.add(stream);
|
|
@@ -46,7 +46,7 @@ export class McpEventStreams {
|
|
|
46
46
|
}
|
|
47
47
|
stream.cursor=page.cursor;more=page.hasMore;
|
|
48
48
|
}
|
|
49
|
-
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+
|
|
49
|
+
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Math.floor(Date.now()/1000)+60).run();
|
|
50
50
|
await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
|
|
51
51
|
}catch(error){
|
|
52
52
|
const details=eventError(error);
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
|
|
|
78
78
|
const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
|
|
79
79
|
if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
80
80
|
if(previous&&!live)await removeEventHook(env,id);
|
|
81
|
-
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key
|
|
81
|
+
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;')").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
|
|
82
82
|
const start=await eventStart(env,params,now());
|
|
83
83
|
const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
|
|
84
84
|
if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
|
|
@@ -98,15 +98,15 @@ export async function unsubscribeEvents(env,caller,params){
|
|
|
98
98
|
/** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
|
|
99
99
|
export async function eventHookSubscriptions(env,cat,options={}){
|
|
100
100
|
const now=options.now??Date.now,definitions=[];let after='';
|
|
101
|
-
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key
|
|
101
|
+
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
102
102
|
for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
|
|
103
103
|
let feed;
|
|
104
104
|
try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
105
|
-
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
|
|
105
|
+
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,sub.id);continue;}
|
|
106
106
|
definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
|
|
107
107
|
const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
|
|
108
108
|
try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
|
|
109
|
-
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
|
|
109
|
+
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,fresh.id);return;}
|
|
110
110
|
try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
|
|
111
111
|
catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
|
|
112
112
|
}});
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** mcp_kv.expires is Unix seconds for ALL rows (OAuth, webhook dedupe and streams); NULL never expires. */
|
|
1
2
|
/** OAuth's KV contract on the studio's existing D1. No new Cloudflare resource. */
|
|
2
3
|
export const MCP_MIGRATION_FILE = '0017_studio_mcp.sql';
|
|
3
4
|
export const MCP_MIGRATION = `CREATE TABLE IF NOT EXISTS mcp_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER);
|
|
@@ -6,6 +7,15 @@ CREATE TABLE IF NOT EXISTS mcp_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, pers
|
|
|
6
7
|
CREATE TABLE IF NOT EXISTS mcp_limits (caller TEXT NOT NULL, minute INTEGER NOT NULL, n INTEGER NOT NULL, PRIMARY KEY(caller, minute));
|
|
7
8
|
CREATE TABLE IF NOT EXISTS tool_data (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(namespace,key));
|
|
8
9
|
`;
|
|
10
|
+
/** Exclusive bound in SQLite's binary text order; null means no upper bound. */
|
|
11
|
+
export function prefixEnd(prefix) {
|
|
12
|
+
const points=Array.from(prefix, c=>c.codePointAt(0));
|
|
13
|
+
while(points.length) {
|
|
14
|
+
const last=points.pop();
|
|
15
|
+
if(last<0x10ffff)return String.fromCodePoint(...points,last===0xd7ff?0xe000:last+1);
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
9
19
|
export function oauthStorage(DB) {
|
|
10
20
|
return {
|
|
11
21
|
async get(key, options) {
|
|
@@ -19,7 +29,8 @@ export function oauthStorage(DB) {
|
|
|
19
29
|
async delete(key) { await DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(key).run(); },
|
|
20
30
|
async list({prefix = '', cursor = '', limit = 1000} = {}) {
|
|
21
31
|
const n = Math.max(1,Math.min(1000,limit));
|
|
22
|
-
const
|
|
32
|
+
const end=prefixEnd(prefix);
|
|
33
|
+
const rows = (await DB.prepare(`SELECT key,expires FROM mcp_kv WHERE key>=?1 ${end===null?'':'AND key<?2'} AND key>?3 AND (expires IS NULL OR expires>?4) ORDER BY key LIMIT ?5`).bind(prefix,end,cursor,Math.floor(Date.now()/1000),n+1).all()).results;
|
|
23
34
|
return { keys: rows.slice(0,n).map(r=>({name:r.key,...(r.expires ? {expiration:r.expires}: {})})), list_complete: rows.length<=n, cursor: rows.length>n ? rows[n-1].key : '' };
|
|
24
35
|
},
|
|
25
36
|
};
|
package/worker/mcp.mjs
CHANGED
|
@@ -52,7 +52,11 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
52
52
|
}
|
|
53
53
|
const cat=await catalogueOf();
|
|
54
54
|
const tools=[...builtins(env,cat,origin),...definitions];
|
|
55
|
-
|
|
55
|
+
// Catalogue notifications update existing streams; the caller's response
|
|
56
|
+
// uses the catalogue above and does not depend on that delivery finishing.
|
|
57
|
+
const refresh=pushToolList(env,cat,tools,origin);
|
|
58
|
+
if(ctx?.waitUntil)ctx.waitUntil(refresh);
|
|
59
|
+
else await refresh;
|
|
56
60
|
if(request.method==='GET'&&env.LOBBY) {
|
|
57
61
|
if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
|
|
58
62
|
const connection=caller.connection??'public:'+crypto.randomUUID();
|