@homie-rocks/studio 0.54.0 → 0.56.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 +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -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/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -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/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -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-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/rules/host.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface HostOptions {
|
|
|
87
87
|
restoreEpoch?: number;
|
|
88
88
|
startDelayMs?: number;
|
|
89
89
|
onTick?(): void;
|
|
90
|
+
/** Suspend settled round breaks; the owner persists and wakes this deadline. */
|
|
91
|
+
onRoundIdle?(until: number): void;
|
|
90
92
|
/** Only the shared server isolate can attribute timer lateness to another tick. */
|
|
91
93
|
enforceOverrun?: boolean;
|
|
92
94
|
log?(line: Record<string, unknown>): void;
|
|
@@ -111,6 +113,8 @@ export interface Host {
|
|
|
111
113
|
readonly epoch: number;
|
|
112
114
|
readonly running: boolean;
|
|
113
115
|
readonly paused: boolean;
|
|
116
|
+
readonly roundIdleUntil: number | null;
|
|
117
|
+
wakeRound(): void;
|
|
114
118
|
readonly people: number;
|
|
115
119
|
readonly viewRadiusM: number | null;
|
|
116
120
|
readonly privateDelivery: boolean;
|
|
@@ -199,13 +203,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
199
203
|
// A log that throws takes nothing down with it.
|
|
200
204
|
const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
|
|
201
205
|
const random = o.random ?? Math.random;
|
|
202
|
-
const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
206
|
+
const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
203
207
|
if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
|
|
204
208
|
if (saved) {
|
|
205
209
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
206
210
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
207
|
-
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
|
|
211
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
|
|
208
212
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
213
|
+
if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
|
|
209
214
|
if (saved.characters !== undefined) {
|
|
210
215
|
check(c.settings.characters && Array.isArray(saved.characters));
|
|
211
216
|
check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
|
|
@@ -396,6 +401,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
396
401
|
core.goal(b.seat, g?.state === 'active' ? { goal: g.goal, args: g.args, from: g.from, at: Math.round(g.at / period), asked: g.asked === true } : null);
|
|
397
402
|
}
|
|
398
403
|
}
|
|
404
|
+
let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
|
|
399
405
|
let running = false;
|
|
400
406
|
let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
|
|
401
407
|
let ended = false;
|
|
@@ -594,7 +600,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
594
600
|
core.seatLeave(held.seat);
|
|
595
601
|
tick(o.clock.now());
|
|
596
602
|
} else core.seatAway(m.seat as number, true);
|
|
597
|
-
if (running && people() === 0) pause();
|
|
603
|
+
if ((running || roundIdleUntil !== null) && people() === 0) pause();
|
|
598
604
|
return;
|
|
599
605
|
}
|
|
600
606
|
case 'free': {
|
|
@@ -832,7 +838,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
832
838
|
}
|
|
833
839
|
timing.record('tick', tickAt);
|
|
834
840
|
if (ended) return;
|
|
835
|
-
if (!faulted && core.stats.ticksCut === cut) {
|
|
841
|
+
if (!faulted && core.stats.ticksCut === cut) {
|
|
842
|
+
failingSince = null;
|
|
843
|
+
const round = core.round();
|
|
844
|
+
if (o.onRoundIdle && running && round.phase === 'over' && round.results && round.endsAt > core.tick) {
|
|
845
|
+
roundIdleUntil = now + (round.endsAt - core.tick) * period;
|
|
846
|
+
running = false; disarm();
|
|
847
|
+
try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
|
|
848
|
+
catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
836
852
|
if (failingSince === null) failingSince = now;
|
|
837
853
|
if (now - failingSince < FAIL_MS) return;
|
|
838
854
|
if (faulted) end('fault', { error: stats.lastFault, faults: stats.faults });
|
|
@@ -913,7 +929,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
913
929
|
const self = { blame: (): void => { blamed = true; } };
|
|
914
930
|
|
|
915
931
|
function start(): void {
|
|
916
|
-
if (running || ended) return;
|
|
932
|
+
if (running || ended || roundIdleUntil !== null) return;
|
|
917
933
|
running = true; paused = false;
|
|
918
934
|
try {
|
|
919
935
|
base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
|
|
@@ -926,26 +942,37 @@ export function createHost(o: HostOptions): Host {
|
|
|
926
942
|
} catch (error) { fault('start', error); } finally { keepTime(); }
|
|
927
943
|
}
|
|
928
944
|
function pause(): void {
|
|
929
|
-
if (!running)
|
|
945
|
+
if (!running) {
|
|
946
|
+
if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
930
949
|
running = false; paused = true;
|
|
931
950
|
// A pending timer would keep the object in memory and billed: the pause clears it.
|
|
932
951
|
disarm();
|
|
933
952
|
log({ ev: 'host-pause', game: o.game, tick: core.tick });
|
|
934
953
|
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
|
|
935
954
|
}
|
|
936
|
-
function resume(): void {
|
|
937
|
-
if (running || ended || !paused) return;
|
|
955
|
+
function resume(announceRound = true): void {
|
|
956
|
+
if (running || ended || !paused || roundIdleUntil !== null) return;
|
|
938
957
|
running = true; paused = false;
|
|
939
958
|
try {
|
|
940
959
|
// The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
|
|
941
960
|
base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
|
|
942
961
|
overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
|
|
943
|
-
handle([core.round()], base.ms);
|
|
962
|
+
if (announceRound) handle([core.round()], base.ms);
|
|
944
963
|
for (const q of queues.values()) q.lastFrameTick = core.tick;
|
|
945
964
|
log({ ev: 'host-resume', game: o.game, tick: core.tick });
|
|
946
965
|
o.onResume?.();
|
|
947
966
|
} catch (error) { fault('resume', error); } finally { keepTime(); }
|
|
948
967
|
}
|
|
968
|
+
function wakeRound(): void {
|
|
969
|
+
if (roundIdleUntil === null || o.clock.now() < roundIdleUntil || ended) return;
|
|
970
|
+
if (!core.resumeRound()) throw new Error('Round is not settled');
|
|
971
|
+
roundIdleUntil = null;
|
|
972
|
+
handle(core.drain(), o.clock.now());
|
|
973
|
+
if (people() > 0) { paused = true; resume(false); }
|
|
974
|
+
else { paused = true; checkpoint(true); }
|
|
975
|
+
}
|
|
949
976
|
/** What a relay keeps for whoever joins next, said again: the round with its results, the roster, the shared state, the capabilities. */
|
|
950
977
|
function announce(): void {
|
|
951
978
|
if (ended) return;
|
|
@@ -975,7 +1002,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
975
1002
|
|
|
976
1003
|
function save(): Uint8Array {
|
|
977
1004
|
const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
|
|
978
|
-
return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
1005
|
+
return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
979
1006
|
}
|
|
980
1007
|
function checkpoint(force = false): void {
|
|
981
1008
|
if (persistent) {
|
|
@@ -1022,7 +1049,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
1022
1049
|
held.character = core.character(held.seat) ?? held.character; held.returning = true;
|
|
1023
1050
|
present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
|
|
1024
1051
|
if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
|
|
1025
|
-
if (running && people() === 0) pause();
|
|
1052
|
+
if ((running || roundIdleUntil !== null) && people() === 0) pause();
|
|
1026
1053
|
},
|
|
1027
1054
|
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
1028
1055
|
get viewSettings() { return c.settings.view; },
|
|
@@ -1032,6 +1059,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
1032
1059
|
get epoch() { return epoch; },
|
|
1033
1060
|
get running() { return running; },
|
|
1034
1061
|
get paused() { return paused; },
|
|
1062
|
+
get roundIdleUntil() { return roundIdleUntil; },
|
|
1063
|
+
wakeRound,
|
|
1035
1064
|
get people() { return people(); },
|
|
1036
1065
|
frame, start, pause, resume, announce,
|
|
1037
1066
|
// An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
|
package/rules/view.ts
CHANGED
|
@@ -941,6 +941,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
941
941
|
net.on('snapshot', onSnapshot);
|
|
942
942
|
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
943
943
|
net.on('link', (e) => {
|
|
944
|
+
if (e.why === 'subscribe') return;
|
|
944
945
|
if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
945
946
|
});
|
|
946
947
|
net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
|
package/tools/TOOLS.md
CHANGED
|
@@ -53,7 +53,12 @@ Workers AI requires the studio's own binding and owner authority, or an explicit
|
|
|
53
53
|
never exposes the environment or raw credentials. A named secret capability in
|
|
54
54
|
`studio.json` (`mcp.secrets.NAME: { origin, header, prefix, tools }`) lets an owner tool
|
|
55
55
|
make an HTTPS request to one declared origin; `tools` can explicitly delegate this capability to named staff tools; redirects and credential echoes are
|
|
56
|
-
refused.
|
|
56
|
+
refused. Use `origin: 'self'` for the studio's own API: the capability resolves to
|
|
57
|
+
the current Worker request origin (or the configured studio origin for scheduled
|
|
58
|
+
functions), so a workers.dev address need not be known at build time. Calls may
|
|
59
|
+
use a relative URL such as `ctx.fetchWithSecret('API_TOKEN', '/api/sync')`.
|
|
60
|
+
HTTPS is still required; credentials in URLs and cross-origin targets are refused.
|
|
61
|
+
The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
|
|
57
62
|
|
|
58
63
|
Studio handlers and imported parts are trusted Worker code, not sandboxed plugins.
|
|
59
64
|
Review shared code before adding it: JavaScript wrappers cannot confine malicious
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Page counters are sampled analytics, never billing or gameplay state.
|
|
2
|
+
* D1 retains historical totals. Each rollup adds only the delta from the last
|
|
3
|
+
* observed total, in the same transaction as its watermark, so retries are safe.
|
|
4
|
+
*/
|
|
5
|
+
export const PAGE_METRICS = new Set(['visit', 'play', 'screen', 'watch']);
|
|
6
|
+
export function writePageMetric(env, row) {
|
|
7
|
+
if (!env.STUDIO_ANALYTICS || !env.STUDIO_ANALYTICS_DATASET || !PAGE_METRICS.has(row.metric)) return false;
|
|
8
|
+
const day = row.day ?? new Date().toISOString().slice(0, 10);
|
|
9
|
+
try { env.STUDIO_ANALYTICS.writeDataPoint({
|
|
10
|
+
indexes: [String(row.subject ?? '').slice(0, 80) || 'studio'],
|
|
11
|
+
blobs: [day, row.metric, String(row.subject ?? '').slice(0, 80), String(row.source ?? '').slice(0, 80)],
|
|
12
|
+
doubles: [Math.max(0, Math.floor(Number(row.n ?? 1) || 0))],
|
|
13
|
+
}); } catch (error) {
|
|
14
|
+
// Analytics is sampled telemetry. A binding failure must not fail a page or
|
|
15
|
+
// amplify the outage into a D1 write on every visit.
|
|
16
|
+
console.warn('Studio analytics point dropped:', error.message);
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
async function* analyticsPages(env,{now=Date.now(),pageSize=1000}={}) {
|
|
21
|
+
if (!env.ANALYTICS_SQL || !env.STUDIO_ANALYTICS_DATASET) return;
|
|
22
|
+
const dataset=String(env.STUDIO_ANALYTICS_DATASET);
|
|
23
|
+
if(!/^[a-z0-9_-]+$/.test(dataset))throw new Error('Invalid studio analytics dataset');
|
|
24
|
+
// Revisit each UTC day for late ingestion and sample corrections. Ordered
|
|
25
|
+
// pages avoid the aggregate 50k result ceiling, including a busy single day.
|
|
26
|
+
for(let ago=7;ago>=0;ago--) {
|
|
27
|
+
const day=new Date(now-ago*86400000).toISOString().slice(0,10);
|
|
28
|
+
for(let offset=0;;offset+=pageSize) {
|
|
29
|
+
const result=await env.ANALYTICS_SQL.query({
|
|
30
|
+
query:`SELECT blob1 AS day, blob2 AS metric, blob3 AS subject, blob4 AS source, SUM(_sample_interval * double1) AS n
|
|
31
|
+
FROM events.analyticsEngine."${dataset}" WHERE timestamp >= $start AND blob1 = $day
|
|
32
|
+
GROUP BY blob1, blob2, blob3, blob4 ORDER BY blob1, blob2, blob3, blob4 LIMIT ${pageSize} OFFSET $offset`,
|
|
33
|
+
params:{start:day+'T00:00:00Z',day,offset},
|
|
34
|
+
});
|
|
35
|
+
if(!Array.isArray(result.data))throw new Error('Incomplete studio analytics rollup');
|
|
36
|
+
yield result.data.filter(r=>/^\d{4}-\d{2}-\d{2}$/.test(r.day)&&PAGE_METRICS.has(r.metric)&&Number.isFinite(Number(r.n)));
|
|
37
|
+
if(result.data.length<pageSize)break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function analyticsTotals(env,now=Date.now()) {
|
|
42
|
+
const rows=[];for await(const page of analyticsPages(env,{now}))rows.push(...page);return rows;
|
|
43
|
+
}
|
|
44
|
+
export async function analyticsStatus(env,now=Date.now()) {
|
|
45
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM meta WHERE key IN ('analytics-rollup-error','analytics-rollup-success')").all()).results??[];
|
|
46
|
+
const values=Object.fromEntries(rows.map(r=>[r.key,r.value]));
|
|
47
|
+
const error=values['analytics-rollup-error'];
|
|
48
|
+
return {lastSuccess:Number(values['analytics-rollup-success'])||null,delayed:Boolean(error)||now-Number(values['analytics-rollup-success']??0)>7200000,error:error||null};
|
|
49
|
+
}
|
|
50
|
+
async function health(env,key,value) {
|
|
51
|
+
await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE value!=excluded.value').bind(key,String(value)).run();
|
|
52
|
+
}
|
|
53
|
+
export async function rollupAnalytics(env,options={}) {
|
|
54
|
+
if(!env.DB||!env.ANALYTICS_SQL)return;
|
|
55
|
+
try {
|
|
56
|
+
for await(const rows of analyticsPages(env,options)) {
|
|
57
|
+
for(let offset=0;offset<rows.length;offset+=40) {
|
|
58
|
+
const statements=rows.slice(offset,offset+40).flatMap(r=>{
|
|
59
|
+
const args=[r.day,r.metric,r.subject,r.source,Math.round(Number(r.n))];
|
|
60
|
+
return [
|
|
61
|
+
env.DB.prepare(`INSERT INTO stats_daily(day,metric,subject,source,n)
|
|
62
|
+
SELECT ?1,?2,?3,?4,?5-COALESCE((SELECT n FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4),0)
|
|
63
|
+
WHERE NOT EXISTS(SELECT 1 FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4 AND n=?5)
|
|
64
|
+
ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=stats_daily.n+excluded.n`).bind(...args),
|
|
65
|
+
env.DB.prepare(`INSERT INTO stats_analytics_daily(day,metric,subject,source,n) VALUES(?1,?2,?3,?4,?5)
|
|
66
|
+
ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=excluded.n WHERE n!=excluded.n`).bind(...args),
|
|
67
|
+
];
|
|
68
|
+
});
|
|
69
|
+
if(statements.length)await env.DB.batch(statements);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await health(env,'analytics-rollup-error','');
|
|
73
|
+
await health(env,'analytics-rollup-success',options.now??Date.now());
|
|
74
|
+
return {delayed:false};
|
|
75
|
+
} catch(error) {
|
|
76
|
+
console.error('Studio analytics counts delayed:',error.message);
|
|
77
|
+
await health(env,'analytics-rollup-error',String(error.message).slice(0,500));
|
|
78
|
+
return {delayed:true,error:error.message};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const ANALYTICS_MIGRATION_FILE="0037_owner_analytics.sql";
|
|
83
|
+
export const ANALYTICS_MIGRATION=`CREATE TABLE IF NOT EXISTS stats_analytics_daily(day TEXT NOT NULL,metric TEXT NOT NULL,subject TEXT NOT NULL,source TEXT NOT NULL,n INTEGER NOT NULL,PRIMARY KEY(day,metric,subject,source)) WITHOUT ROWID;
|
|
84
|
+
`;
|
package/worker/app-frame.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
|
|
2
2
|
export const APP_STORAGE_JS=String.raw`(()=>{
|
|
3
3
|
for(const name of ['localStorage','sessionStorage']) {
|
|
4
|
-
try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
|
|
4
|
+
try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{/* A blocked storage probe falls through to the in-memory shim. */}
|
|
5
5
|
const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
|
|
6
6
|
const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
|
|
7
7
|
Object.defineProperty(window,name,{value:storage,configurable:true});
|
package/worker/app-graphics.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
|
|
2
2
|
if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
|
|
3
|
-
try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
|
|
3
|
+
try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{/* Context creation can throw when graphics are unavailable; show the fallback. */}
|
|
4
4
|
const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
|
|
5
5
|
p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
|
|
6
6
|
const canvas=document.querySelector('canvas');
|
package/worker/app-streams.mjs
CHANGED
|
@@ -10,11 +10,11 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
|
|
|
10
10
|
END;`;}).join('\n')}
|
|
11
11
|
`;
|
|
12
12
|
const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
|
|
13
|
-
export const appHub=(env,app)=>env.LOBBY.get(
|
|
13
|
+
export const appHub=(env,app)=>{const ns=env.APP_RECORDS??env.LOBBY;return ns.get(ns.idFromName('@app-records/'+app));};
|
|
14
14
|
const pushedRevisions=new WeakMap();
|
|
15
15
|
export async function pushAppRecords(env,cat){
|
|
16
16
|
const apps=(cat.games??[]).filter(g=>g.kind==='app');
|
|
17
|
-
if(!env.LOBBY||!env.DB||!apps.length)return;
|
|
17
|
+
if(!(env.APP_RECORDS??env.LOBBY)||!env.DB||!apps.length)return;
|
|
18
18
|
const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
|
|
19
19
|
UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
|
|
20
20
|
const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
|
|
@@ -32,7 +32,7 @@ export async function pushAppRecords(env,cat){
|
|
|
32
32
|
export class AppRecordStreams {
|
|
33
33
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
34
34
|
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
35
|
-
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{}this.presence();}
|
|
35
|
+
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
|
|
36
36
|
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
37
37
|
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
38
38
|
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {catalogue} from './catalogue.mjs';
|
|
2
|
+
/** Durable rows remain the source of truth. Messages carry wakeups, never records. */
|
|
3
|
+
export async function enqueueChanges(env, origin) {
|
|
4
|
+
if(env.BACKGROUND) {
|
|
5
|
+
try {return await env.BACKGROUND.send({v:1,type:'studio.changed',origin});}
|
|
6
|
+
catch(error) {console.warn('Background queue unavailable; delivering inline:',error.message);}
|
|
7
|
+
}
|
|
8
|
+
// Older wrappers and local unit fixtures without the binding keep their behavior.
|
|
9
|
+
return (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
|
|
10
|
+
}
|
|
11
|
+
export async function consumeBackground(batch, env) {
|
|
12
|
+
const changes=batch.messages.filter(m=>m.body?.v===1&&m.body.type==='studio.changed');
|
|
13
|
+
if(changes.length) {
|
|
14
|
+
try {
|
|
15
|
+
const origin=changes[0].body.origin;
|
|
16
|
+
await (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
|
|
17
|
+
for(const message of changes)message.ack();
|
|
18
|
+
} catch(error) {
|
|
19
|
+
console.error('Background delivery failed',error);
|
|
20
|
+
for(const message of changes)message.retry({delaySeconds:30});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for(const message of batch.messages)if(!changes.includes(message)) {
|
|
24
|
+
try {
|
|
25
|
+
if(message.body?.v===1&&message.body.type==='lobby.snapshot'){await (await import('./lobby-summary.mjs')).storeLobbySummary(env,message.body);message.ack();continue;}
|
|
26
|
+
if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
|
|
27
|
+
await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
|
|
28
|
+
message.ack();
|
|
29
|
+
} catch(error) {console.error('Background queue delivery failed',error);message.retry({delaySeconds:error.retryAfterSeconds??30});}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.56.0","sha256":"9b000afecb22ce5781d6499a02f44ffcc255c61907e9f7d469dd63d0bd9f35d1"});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const cache = new WeakMap();
|
|
2
|
+
/** Cache completed immutable data only. Never share pending I/O across requests. */
|
|
3
|
+
export async function catalogue(env, origin = 'https://studio.invalid') {
|
|
4
|
+
const baked = (await import('./built-metadata.mjs')).default;
|
|
5
|
+
if (baked) return baked.catalogue;
|
|
6
|
+
const hit = cache.get(env.ASSETS);
|
|
7
|
+
if (hit?.origin === origin && env.HOMIE_PREVIEW !== '1' && env.HOMIE_DEV !== '1') return hit.value;
|
|
8
|
+
const response = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
|
|
9
|
+
if (!response.ok) return {studio: {name: env.STUDIO_NAME || 'Studio'}, games: []};
|
|
10
|
+
const tag=response.headers.get('etag');
|
|
11
|
+
if(tag&&hit?.origin===origin&&hit.tag===tag){await response.body?.cancel();return hit.value;}
|
|
12
|
+
const value = await response.json();
|
|
13
|
+
cache.set(env.ASSETS, {origin, value, tag});
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export async function builtPosts() {
|
|
17
|
+
return (await import('./built-metadata.mjs')).default?.posts;
|
|
18
|
+
}
|
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'];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Studio configuration only: register once at module evaluation, never per request. */
|
|
2
|
+
let handler = null;
|
|
3
|
+
export function useRoutes(routes) {
|
|
4
|
+
if (routes !== null && typeof routes !== 'function') throw new TypeError('useRoutes expects a function or null');
|
|
5
|
+
handler = routes;
|
|
6
|
+
}
|
|
7
|
+
export async function customRoute(request, env, ctx) {
|
|
8
|
+
const response = await handler?.(request, env, ctx);
|
|
9
|
+
if (response == null) return null;
|
|
10
|
+
if (!(response instanceof Response)) throw new TypeError('A custom route must return a Response, null, or undefined');
|
|
11
|
+
if (response.status === 101 || response.webSocket) return response;
|
|
12
|
+
const headers = new Headers(response.headers);
|
|
13
|
+
headers.set('x-content-type-options', 'nosniff');
|
|
14
|
+
if (!headers.has('referrer-policy')) headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
|
15
|
+
return new Response(response.body, {status:response.status,statusText:response.statusText,headers});
|
|
16
|
+
}
|
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
|
}
|
|
@@ -50,14 +50,14 @@ export async function subscribeFeed(env,cat,caller,uri,remove=false) {
|
|
|
50
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();
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
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));}
|
|
54
54
|
export async function pushEvents(env,cat,origin) {
|
|
55
|
-
if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
|
|
56
|
-
//
|
|
57
|
-
|
|
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.
|
|
58
58
|
let after='';const seen=new Set();
|
|
59
59
|
for(;;){
|
|
60
|
-
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;
|
|
61
61
|
for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
|
|
62
62
|
const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
|
|
63
63
|
if(!response.ok)throw new Error('Event stream wake failed');
|
|
@@ -70,7 +70,7 @@ export async function pushEvents(env,cat,origin) {
|
|
|
70
70
|
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
71
71
|
export class EventStreams {
|
|
72
72
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
|
|
73
|
-
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)));}}
|
|
74
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);}));}
|
|
75
75
|
async fetch(request) {
|
|
76
76
|
const input=await request.json();
|
|
@@ -82,7 +82,7 @@ export class EventStreams {
|
|
|
82
82
|
if(this.stream)this.close(this.stream);
|
|
83
83
|
await this.tail;
|
|
84
84
|
const {connection,lastId}=input;
|
|
85
|
-
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;
|
|
86
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='';
|
|
87
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];}
|
|
88
88
|
const stream={...input,after,name,controller:null,cancelled:false,resume:null};
|
|
@@ -92,7 +92,9 @@ export class EventStreams {
|
|
|
92
92
|
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
93
93
|
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
94
94
|
stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
95
|
-
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?.();
|
|
96
98
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
97
99
|
// hold up the writer's request or a function handler.
|
|
98
100
|
this.background(input);
|
|
@@ -100,7 +102,7 @@ export class EventStreams {
|
|
|
100
102
|
}
|
|
101
103
|
this.background(input);return new Response(null,{status:204});
|
|
102
104
|
}
|
|
103
|
-
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;}
|
|
104
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);}
|
|
105
107
|
async deliver(input) {
|
|
106
108
|
const stream=this.stream;if(!stream)return;
|
|
@@ -113,7 +115,7 @@ export class EventStreams {
|
|
|
113
115
|
const signature=await this.toolSignature(input.tools,caller);
|
|
114
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'));}
|
|
115
117
|
}
|
|
116
|
-
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;
|
|
117
119
|
const subscriptions=[];
|
|
118
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);}
|
|
119
121
|
const through=(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
|
|
@@ -155,9 +157,19 @@ export async function pushToolList(env,cat,tools,origin) {
|
|
|
155
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();
|
|
156
158
|
let after='';
|
|
157
159
|
for(;;){
|
|
158
|
-
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;
|
|
159
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})});}
|
|
160
162
|
if(rows.length<100)break;after=rows.at(-1).key;
|
|
161
163
|
}
|
|
162
164
|
toolCatalogues.set(binding,signature);
|
|
163
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
|
+
}
|
|
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`)); }
|