@homie-rocks/studio 0.58.7 → 0.58.11
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 +46 -0
- package/README.md +34 -0
- package/bin/homie-studio.mjs +19 -2
- package/guides/art/scripts/art.mjs +4 -3
- package/guides/game/RULES.md +2 -0
- package/guides/game/SKILL.md +2 -0
- package/guides/playtest/scripts/playtest.mjs +4 -4
- package/guides/video/scripts/lib/browser.mjs +23 -2
- package/lib/check.mjs +4 -4
- package/lib/chrome.mjs +23 -0
- package/lib/cloudflare.mjs +18 -8
- package/lib/crowd-load.mjs +2 -1
- package/lib/dispatch-target.mjs +49 -0
- package/lib/lab-check.mjs +3 -3
- package/lib/look.mjs +3 -3
- package/lib/mcp-tools.mjs +10 -0
- package/lib/move-drain.mjs +18 -0
- package/lib/perf.mjs +3 -3
- package/lib/port-check.mjs +3 -3
- package/lib/read-only.mjs +69 -0
- package/lib/render3d.mjs +3 -3
- package/lib/rules-guard.mjs +30 -12
- package/lib/shoot.mjs +3 -3
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/starters/block-drop/README.md +2 -0
- package/starters/block-drop/game.json +1 -1
- package/starters/block-drop/index.html +1 -1
- package/starters/block-drop/src/view.ts +34 -8
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +36 -9
- package/worker/lobby.mjs +2 -1
- package/worker/managed-media.mjs +16 -0
- package/worker/move-drain.mjs +47 -0
- package/worker/read-only.mjs +113 -0
- package/worker/routes.mjs +12 -1
- package/worker/stream-objects.mjs +3 -2
- package/worker/table.mjs +10 -1
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {LOBBY_SHARDS} from './lobby-shards.mjs';
|
|
2
|
+
export const DRAIN_KEY='studio_move_drain';
|
|
3
|
+
/** How long an isolate trusts its last reading of the drain flag. A move's census waits minutes after the drain
|
|
4
|
+
* starts (drainStatus), so an admission a few seconds late is still counted; steady seats and sockets cost no D1 read. */
|
|
5
|
+
export const DRAIN_TTL_MS=5000;
|
|
6
|
+
const readings=new WeakMap();
|
|
7
|
+
export async function drainSince(env,{now=Date.now(),fresh=false}={}){
|
|
8
|
+
if(!env.DB)return null;
|
|
9
|
+
const cached=readings.get(env.DB);
|
|
10
|
+
if(!fresh&&cached&&now-cached.at<DRAIN_TTL_MS)return cached.since;
|
|
11
|
+
const db=env.DB.withSession?env.DB.withSession('first-primary'):env.DB;
|
|
12
|
+
const row=await db.prepare('SELECT value FROM meta WHERE key=?1').bind(DRAIN_KEY).first();
|
|
13
|
+
const at=Number(row?.value),since=Number.isSafeInteger(at)&&at>0?at:null;
|
|
14
|
+
readings.set(env.DB,{at:now,since});
|
|
15
|
+
return since;
|
|
16
|
+
}
|
|
17
|
+
export function isAdmission(request){const path=new URL(request.url).pathname;return request.headers.get('upgrade')?.toLowerCase()==='websocket'||/\/api\/(lobby|agents)(\/|$)/.test(path);}
|
|
18
|
+
export const drainResponse=()=>Response.json({ok:false,error:'moving',message:'This studio is moving. Existing rooms can finish; please join again shortly.'},{status:503,headers:{'retry-after':'30','cache-control':'no-store'}});
|
|
19
|
+
/** Read every shard directly: cached public summaries exclude private games and hide outages. */
|
|
20
|
+
export async function drainStatus(env,cat,{now=Date.now()}={}){
|
|
21
|
+
const since=await drainSince(env,{fresh:true});if(!since)return {draining:false,drained:false};
|
|
22
|
+
let clients=0,pending=0,rooms=0;
|
|
23
|
+
for(const game of cat.games??[]){
|
|
24
|
+
const names=new Set();
|
|
25
|
+
let cursor='';
|
|
26
|
+
for(;;){
|
|
27
|
+
const rows=(await env.DB.prepare("SELECT key FROM meta WHERE key LIKE ?1 AND key>?2 ORDER BY key LIMIT 500").bind('move-room:'+game.id+':%',cursor).all()).results;
|
|
28
|
+
if(!Array.isArray(rows))throw Error('Room admission registry unavailable');
|
|
29
|
+
for(const row of rows)names.add(row.key.slice(('move-room:'+game.id+':').length));
|
|
30
|
+
if(rows.length<500)break;cursor=rows.at(-1).key;
|
|
31
|
+
}
|
|
32
|
+
for(let shard=0;shard<LOBBY_SHARDS;shard++){
|
|
33
|
+
const stub=env.LOBBY.get(env.LOBBY.idFromName(shard?`${game.id}/shard/${shard}`:game.id));
|
|
34
|
+
const r=await stub.fetch('https://lobby/rooms');if(!r.ok)throw Error('Cannot measure rooms while moving');
|
|
35
|
+
const body=await r.json();if(!Array.isArray(body.rooms))throw Error('Invalid room census');
|
|
36
|
+
for(const room of body.rooms)names.add(room.name);
|
|
37
|
+
}
|
|
38
|
+
for(const name of names){
|
|
39
|
+
const r=await env.TABLE.get(env.TABLE.idFromName(`${game.id}/${name}`)).fetch(`https://table/__move-status?game=${encodeURIComponent(game.id)}&room=${encodeURIComponent(name)}`);
|
|
40
|
+
if(!r.ok)throw Error('Cannot measure a room while moving');
|
|
41
|
+
const body=await r.json();
|
|
42
|
+
if(!Number.isSafeInteger(body.clients)||body.clients<0||!Number.isSafeInteger(body.pending)||body.pending<0)throw Error('Invalid room drain receipt');
|
|
43
|
+
clients+=body.clients;pending+=body.pending;rooms++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {draining:true,since,clients,pending,rooms,drained:now-since>=240000&&clients===0&&pending===0};
|
|
47
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READ-ONLY: the owner freezes the studio for a few minutes (a move to another host, maintenance) and nothing is lost.
|
|
3
|
+
* `homie-studio read-only on` sets the Worker secret STUDIO_READ_ONLY=1 (lib/read-only.mjs); `off` deletes it. A
|
|
4
|
+
* secret, not a var in wrangler.jsonc, because Cloudflare applies it to the live Worker at once with no build or
|
|
5
|
+
* deploy, and a deploy made while it is on keeps it on.
|
|
6
|
+
*
|
|
7
|
+
* While it is on:
|
|
8
|
+
* - every request other than GET, HEAD and OPTIONS answers 503 with Retry-After, `x-studio-read-only: 1` and
|
|
9
|
+
* {ok:false,error:'read-only',message}; nothing it carried was applied, so the caller sends it again later;
|
|
10
|
+
* - reads keep answering, and every answer carries `x-studio-read-only: 1`; an HTML page shows a short notice;
|
|
11
|
+
* `/api/games` says `readOnly: true`, so a tool checks the switch without sending a write;
|
|
12
|
+
* - D1 refuses writes for every request and every room: a read that would also have written (a visit counted, a
|
|
13
|
+
* sign-in link redeemed) writes nothing, and a page that needed its write answers the same 503;
|
|
14
|
+
* - Stripe's webhook (POST /api/shop/hook) and the queued reconciliation of its events keep the whole database, so
|
|
15
|
+
* a payment already made is still recorded and fulfilled;
|
|
16
|
+
* - live rooms keep playing (their sockets and matchmaking are GETs, their state is the room's own storage); what a
|
|
17
|
+
* room would write to D1 waits: round records and function events stay in the room's outbox and are delivered once
|
|
18
|
+
* the studio is open again, counters are retried with the next report;
|
|
19
|
+
* - scheduled maintenance is skipped for its tick, except the recovery of Stripe events; queued work other than
|
|
20
|
+
* Stripe events is retried after the read-only window.
|
|
21
|
+
*/
|
|
22
|
+
export const READ_ONLY_HEADER = 'x-studio-read-only';
|
|
23
|
+
export const READ_ONLY_RETRY_SECONDS = 120;
|
|
24
|
+
export const READ_ONLY_MESSAGE = 'This studio is read-only for a few minutes. Nothing was changed: try again shortly.';
|
|
25
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
26
|
+
/** Writes that must go on while the studio is read-only: a payment already made is fulfilled. */
|
|
27
|
+
const WRITES_KEPT = new Set(['POST /api/shop/hook']);
|
|
28
|
+
|
|
29
|
+
export const isReadOnly = (env) => /^(1|true|on)$/i.test(String(env?.STUDIO_READ_ONLY ?? '').trim());
|
|
30
|
+
|
|
31
|
+
export class ReadOnlyError extends Error {
|
|
32
|
+
constructor() { super('read-only: this studio is not writing to its database for a few minutes'); this.name = 'ReadOnlyError'; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readOnlyResponse(extra = {}) {
|
|
36
|
+
return new Response(`${JSON.stringify({ ok: false, error: 'read-only', message: READ_ONLY_MESSAGE })}\n`, {
|
|
37
|
+
status: 503,
|
|
38
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(READ_ONLY_RETRY_SECONDS), [READ_ONLY_HEADER]: '1', ...extra },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a request may do while the studio is read-only: 'refuse' it, 'read' (D1 refuses writes), or 'write'. */
|
|
43
|
+
export function readOnlyAccess(request, path) {
|
|
44
|
+
if (WRITES_KEPT.has(`${request.method} ${path}`)) return 'write';
|
|
45
|
+
return SAFE_METHODS.has(request.method) ? 'read' : 'refuse';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const WRITE_WORD = /\b(?:INSERT|UPDATE|DELETE|REPLACE|UPSERT|CREATE|DROP|ALTER|ATTACH|DETACH|VACUUM|REINDEX)\b/i;
|
|
49
|
+
/** A statement that cannot change the database: SELECT, VALUES, a WITH that only selects, EXPLAIN, or a PRAGMA read. */
|
|
50
|
+
export function readsOnly(sql) {
|
|
51
|
+
const text = String(sql ?? '');
|
|
52
|
+
if (/^\s*EXPLAIN\b/i.test(text)) return true;
|
|
53
|
+
if (/^\s*PRAGMA\b/i.test(text)) return !text.includes('=');
|
|
54
|
+
if (/^\s*(?:SELECT|VALUES)\b/i.test(text)) return true;
|
|
55
|
+
return /^\s*WITH\b/i.test(text) && !WRITE_WORD.test(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* D1 that refuses writes: a write rejects with ReadOnlyError (a promise, as D1's own calls are) and never reaches the
|
|
60
|
+
* database; reads and every other property are D1's own. `refused` is called with each refused statement.
|
|
61
|
+
*/
|
|
62
|
+
export function readOnlyDatabase(DB, refused = () => {}) {
|
|
63
|
+
if (!DB) return DB;
|
|
64
|
+
const refuse = (sql) => { refused(sql); return Promise.reject(new ReadOnlyError()); };
|
|
65
|
+
const statement = (stmt, sql) => {
|
|
66
|
+
const guarded = (method) => async (...args) => readsOnly(sql) ? stmt[method](...args) : refuse(sql);
|
|
67
|
+
return { bind: (...args) => statement(stmt.bind(...args), sql), first: guarded('first'), all: guarded('all'), run: guarded('run'), raw: guarded('raw'), _sql: sql, _readOnly: stmt };
|
|
68
|
+
};
|
|
69
|
+
const own = {
|
|
70
|
+
prepare: (sql) => statement(DB.prepare(sql), sql),
|
|
71
|
+
batch: async (list) => {
|
|
72
|
+
const bad = list.find((s) => !s?._readOnly || !readsOnly(s._sql));
|
|
73
|
+
if (bad) return refuse(bad?._sql ?? 'UNKNOWN');
|
|
74
|
+
return DB.batch(list.map((s) => s._readOnly));
|
|
75
|
+
},
|
|
76
|
+
exec: async (sql, ...args) => readsOnly(sql) ? DB.exec(sql, ...args) : refuse(sql),
|
|
77
|
+
withSession: (...args) => readOnlyDatabase(DB.withSession(...args), refused),
|
|
78
|
+
};
|
|
79
|
+
return new Proxy(own, { get(target, key) {
|
|
80
|
+
if (key in target) return target[key];
|
|
81
|
+
const value = DB[key];
|
|
82
|
+
return typeof value === 'function' ? value.bind(DB) : value;
|
|
83
|
+
} });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A Durable Object's env: its D1 refuses writes while the studio is read-only. */
|
|
87
|
+
export const readOnlyEnv = (env) => isReadOnly(env) && env.DB ? { ...env, DB: readOnlyDatabase(env.DB) } : env;
|
|
88
|
+
|
|
89
|
+
/** Queued work while read-only: Stripe events are reconciled now; everything else comes back after the window. */
|
|
90
|
+
export function heldBatch(batch) {
|
|
91
|
+
const now = [];
|
|
92
|
+
for (const message of batch.messages) {
|
|
93
|
+
if (message.body?.v === 1 && message.body.type === 'stripe.event') now.push(message);
|
|
94
|
+
else message.retry({ delaySeconds: READ_ONLY_RETRY_SECONDS });
|
|
95
|
+
}
|
|
96
|
+
return { ...batch, messages: now };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NOTICE = '<aside role="status" data-studio-read-only style="position:fixed;z-index:2147483647;bottom:0;left:0;right:0;text-align:center;background:#1f2937;color:#f9fafb;padding:8px 12px;font:600 14px system-ui,sans-serif;pointer-events:none">Read-only for a few minutes: everything can be seen and played, and changes can be made again shortly.</aside>';
|
|
100
|
+
/** Every answer says the studio is read-only; a page also shows it. */
|
|
101
|
+
export async function markReadOnly(res) {
|
|
102
|
+
if (!res || res.status === 101 || res.webSocket) return res;
|
|
103
|
+
const headers = new Headers(res.headers);
|
|
104
|
+
headers.set(READ_ONLY_HEADER, '1');
|
|
105
|
+
let body = res.body;
|
|
106
|
+
if (/text\/html/i.test(headers.get('content-type') ?? '') && res.status < 300 && body) {
|
|
107
|
+
const html = await res.text();
|
|
108
|
+
body = /<\/body>/i.test(html) ? html.replace(/<\/body>(?![\s\S]*<\/body>)/i, `${NOTICE}</body>`) : html + NOTICE;
|
|
109
|
+
headers.delete('content-length');
|
|
110
|
+
headers.delete('etag');
|
|
111
|
+
}
|
|
112
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers });
|
|
113
|
+
}
|
package/worker/routes.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {drainStatus} from './move-drain.mjs';
|
|
1
2
|
import {lobbyFor} from './lobby-shards.mjs';
|
|
2
3
|
/** Route dispatch; large interfaces and tool runtimes load at their own entry points. */
|
|
3
4
|
import {customRoute} from './custom-routes.mjs';
|
|
@@ -19,6 +20,7 @@ import { isLoungePath, loungeRoutes } from './lounge.mjs';
|
|
|
19
20
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
20
21
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
21
22
|
import { gateFor } from './gate-routing.mjs';
|
|
23
|
+
import { isReadOnly } from './read-only.mjs';
|
|
22
24
|
|
|
23
25
|
import { SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaIndexPage, notFoundPage, postPage, postsPage, roomsPage, sectionsOf, songPage, videoPage, watchOf } from './site.mjs';
|
|
24
26
|
import { cookieValues, countVisit, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats } from './stats.mjs';
|
|
@@ -32,6 +34,8 @@ import { ldScript, studioNode } from './schema.mjs';
|
|
|
32
34
|
import { licenseOf } from './license.mjs';
|
|
33
35
|
import { isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
|
|
34
36
|
import { GAME_ID, POST_SLUG, BROWSER_KEY, json, sellingShop, availableShellShop, postsOf, directoryOf, claimOf, named, roomsOf, MEDIA_SLUG, assetWithRange, mediaObject, ASSET_CACHE, heldInR2, mediaRow, chatWho, reportAllowed, notAHomie, gameDocument, gameAsset, shareDaily, mediaBeat, studioTools, serverLive, weekOf, fallback } from './runtime-support.mjs';
|
|
37
|
+
/** Rooms whose admission row (move-drain.mjs) this isolate already wrote. */
|
|
38
|
+
const REGISTERED_ROOMS = new Set();
|
|
35
39
|
export default async function route(request, env, ctx) {
|
|
36
40
|
const url = new URL(request.url);
|
|
37
41
|
const path = url.pathname;
|
|
@@ -156,9 +160,11 @@ export default async function route(request, env, ctx) {
|
|
|
156
160
|
...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
|
|
157
161
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
158
162
|
}
|
|
163
|
+
if (path === '/api/move-status' && read) return json(await drainStatus(env,await getAll()),200,{'cache-control':'no-store'});
|
|
159
164
|
if (path === '/api/games') {
|
|
160
165
|
const cat = await getCat();
|
|
161
|
-
|
|
166
|
+
// readOnly: the owner's switch (worker/read-only.mjs), here so a tool can check it without sending a write.
|
|
167
|
+
return json({ studio: cat.studio, readOnly: isReadOnly(env), games: (cat.games ?? []).map(g => g.room?.host === 'server' && !hostedGame(g.id) ? { ...g, rulesError: RULES_WIRING_MESSAGE } : g), live: (await roomsOf(env, cat.games ?? [])).live });
|
|
162
168
|
}
|
|
163
169
|
if (path === '/api/rooms') {
|
|
164
170
|
if(!read)return json({ok:false,error:'method'},405);
|
|
@@ -639,6 +645,11 @@ export default async function route(request, env, ctx) {
|
|
|
639
645
|
// one; none on a kids server, none for an AI or a watcher.
|
|
640
646
|
const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
|
|
641
647
|
const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
|
|
648
|
+
// Keep an admission registry independent of expiring lobby reports. A later move
|
|
649
|
+
// must inspect even a quiet named room and its durable outboxes.
|
|
650
|
+
// Once per room in this isolate: the row is idempotent, so later sockets to the same room cost no D1 write.
|
|
651
|
+
const roomKey='move-room:'+game+':'+room;
|
|
652
|
+
if(env.DB&&!isReadOnly(env)&&!REGISTERED_ROOMS.has(roomKey)){await env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES(?1,'1')").bind(roomKey).run();REGISTERED_ROOMS.add(roomKey);}
|
|
642
653
|
const stub = sub === '__net' ? gateFor(env, game, room, max, b) : env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
643
654
|
// Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
|
|
644
655
|
// names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import {readOnlyEnv} from './read-only.mjs';
|
|
1
2
|
/** Streams use independent namespaces so subscribers never warm matchmaking state. */
|
|
2
3
|
export class AppRecordHub {
|
|
3
|
-
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
4
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=readOnlyEnv(env); }
|
|
4
5
|
async records(){const {AppRecordStreams}=await import('./app-streams.mjs');return this.streams??=new AppRecordStreams(this.env,this.ctx);}
|
|
5
6
|
async fetch(request) {
|
|
6
7
|
const streams=await this.records();
|
|
@@ -17,7 +18,7 @@ export class AppRecordHub {
|
|
|
17
18
|
async webSocketError(socket){return (await this.records()).closeSocket(socket);}
|
|
18
19
|
}
|
|
19
20
|
export class EventHub {
|
|
20
|
-
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
21
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=readOnlyEnv(env); }
|
|
21
22
|
async fetch(request) {
|
|
22
23
|
const {EventStreams}=await import('./event-resources.mjs');
|
|
23
24
|
this.streams??=new EventStreams(this.env,this.ctx);
|
package/worker/table.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {holders,regateArgs,verifyControl} from './office.mjs';
|
|
|
22
22
|
|
|
23
23
|
import {json} from './responses.mjs';
|
|
24
24
|
import {catalogue} from './catalogue.mjs';
|
|
25
|
+
import {readOnlyEnv} from './read-only.mjs';
|
|
25
26
|
|
|
26
27
|
import {ROOM_ALARM_MS,ROOM_PAUSE_MS,roomEndAt} from './room-lifecycle.mjs';
|
|
27
28
|
|
|
@@ -29,7 +30,8 @@ export class Table {
|
|
|
29
30
|
constructor(ctx, env) {
|
|
30
31
|
this.ctx = ctx;
|
|
31
32
|
this.watchSockets = new WatchSockets(ctx);
|
|
32
|
-
|
|
33
|
+
// Read-only (worker/read-only.mjs): the room plays on; its D1 writes wait in its outbox or for the next report.
|
|
34
|
+
this.env = readOnlyEnv(env);
|
|
33
35
|
this.room = null;
|
|
34
36
|
this.timer = null;
|
|
35
37
|
this.saved = null;
|
|
@@ -1087,6 +1089,13 @@ export class Table {
|
|
|
1087
1089
|
}
|
|
1088
1090
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1089
1091
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1092
|
+
if (url.pathname === '/__move-status') {
|
|
1093
|
+
await this.flushRoundRecords();await this.flushFunctionEvents();
|
|
1094
|
+
const sql=this.ctx.storage.sql;
|
|
1095
|
+
let pending=0;
|
|
1096
|
+
for(const name of ['round_outbox','function_outbox'])if(sql?.exec("SELECT name FROM sqlite_master WHERE name=?",name).toArray().length)pending+=Number(sql.exec('SELECT COUNT(*) AS n FROM '+name).toArray()[0].n);
|
|
1097
|
+
return json({clients:room.live().length,pending});
|
|
1098
|
+
}
|
|
1090
1099
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1091
1100
|
// A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
|
|
1092
1101
|
if (url.pathname === '/__chat') {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.11';
|