@homie-rocks/studio 0.12.0 → 0.13.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/README.md +86 -7
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +18 -5
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/office.mjs +172 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/setup.mjs +15 -23
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +49 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +197 -40
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/worker/room.mjs
CHANGED
|
@@ -41,6 +41,25 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
|
41
41
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
42
42
|
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
|
|
46
|
+
* hands the room a control: kick a player out (and hold the door for some minutes), mute one, announce a line to
|
|
47
|
+
* everyone, close the room, or change its seats. The Table verifies the owner's signature before it calls
|
|
48
|
+
* `control()`; this file only applies one. What a player sees of them is new optional frames and two new refusals,
|
|
49
|
+
* so a v1 game that knows none of it keeps playing (its shell shows the notice and the banner).
|
|
50
|
+
*/
|
|
51
|
+
export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
|
|
52
|
+
/** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
|
|
53
|
+
export const SPEECH = /^(?:say|chat|emote)/i;
|
|
54
|
+
const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
55
|
+
/** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
|
|
56
|
+
export function browserTag(key) {
|
|
57
|
+
if (!key) return null;
|
|
58
|
+
let h = 2166136261;
|
|
59
|
+
for (const ch of String(key)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
60
|
+
return h.toString(36).padStart(6, '0').slice(-6);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
/**
|
|
45
64
|
* A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
|
|
46
65
|
* "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
|
|
@@ -152,6 +171,17 @@ export class NetRoom {
|
|
|
152
171
|
kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
|
|
153
172
|
};
|
|
154
173
|
this.snapTimes = [];
|
|
174
|
+
/** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
|
|
175
|
+
this.bans = [];
|
|
176
|
+
this.mutes = [];
|
|
177
|
+
this.announcement = null;
|
|
178
|
+
this.closedUntil = 0;
|
|
179
|
+
this.closedWhy = '';
|
|
180
|
+
/** When the room's first seat was taken (its uptime), and what the first hello asked for (`hello.max`). */
|
|
181
|
+
this.openedAt = 0;
|
|
182
|
+
this.askedMax = null;
|
|
183
|
+
/** A launch change waiting for the round to finish: who may stay (ticket kinds), and when it applies at the latest. */
|
|
184
|
+
this.regate = null;
|
|
155
185
|
}
|
|
156
186
|
|
|
157
187
|
/* ------------------------------------------------------------ sockets */
|
|
@@ -160,7 +190,7 @@ export class NetRoom {
|
|
|
160
190
|
attach(conn) {
|
|
161
191
|
const now = this.now();
|
|
162
192
|
const c = {
|
|
163
|
-
id: randomId(6), conn, ip: conn.ip ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
|
|
193
|
+
id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
|
|
164
194
|
device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
|
|
165
195
|
rates: new Map(), drops: [], errAt: 0,
|
|
166
196
|
};
|
|
@@ -175,6 +205,9 @@ export class NetRoom {
|
|
|
175
205
|
watch(conn) {
|
|
176
206
|
this.watchers.add(conn);
|
|
177
207
|
this.sendText(conn, JSON.stringify(this.facts()));
|
|
208
|
+
// A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
|
|
209
|
+
const held = this.heldNotice(conn);
|
|
210
|
+
if (held) this.sendText(conn, JSON.stringify(held));
|
|
178
211
|
return { onClose: () => this.watchers.delete(conn) };
|
|
179
212
|
}
|
|
180
213
|
|
|
@@ -184,13 +217,13 @@ export class NetRoom {
|
|
|
184
217
|
try { conn.send(text); this.stats.bytesOut += text.length; } catch { /* closing */ }
|
|
185
218
|
}
|
|
186
219
|
send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
|
|
187
|
-
error(c, code, message) { this.send(c, { t: 'error', code, message }); }
|
|
220
|
+
error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
|
|
188
221
|
live() { return [...this.clients.values()].filter((c) => c.helloed); }
|
|
189
222
|
others(c) { return this.live().filter((o) => o !== c); }
|
|
190
223
|
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
191
224
|
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
192
225
|
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
193
|
-
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c) }; }
|
|
226
|
+
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(this.muteOf(c) ? { muted: true } : {}) }; }
|
|
194
227
|
peers() { return this.live().map((c) => this.peer(c)); }
|
|
195
228
|
stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
|
|
196
229
|
|
|
@@ -268,6 +301,8 @@ export class NetRoom {
|
|
|
268
301
|
}
|
|
269
302
|
case 'ev': {
|
|
270
303
|
const kind = String(m.k ?? '').slice(0, 64);
|
|
304
|
+
// A muted player's speech goes nowhere (a host relays everyone's, so only a non-host's own is dropped).
|
|
305
|
+
if (!isHost && SPEECH.test(kind) && this.muteOf(c)) { this.stats.mutedDrops = (this.stats.mutedDrops ?? 0) + 1; return; }
|
|
271
306
|
this.stats.evs += 1;
|
|
272
307
|
const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
|
|
273
308
|
if (isHost) {
|
|
@@ -314,6 +349,8 @@ export class NetRoom {
|
|
|
314
349
|
case 'round': {
|
|
315
350
|
if (!isHost || !m.round || typeof m.round !== 'object') return;
|
|
316
351
|
this.lastRound = m.round;
|
|
352
|
+
// The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
|
|
353
|
+
if (this.regate && m.round.phase === 'over' && (this.regate.roundN === null || Number(m.round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
|
|
317
354
|
this.stats.rounds += 1;
|
|
318
355
|
this.persistDirty = true;
|
|
319
356
|
for (const o of this.others(c)) this.send(o, { t: 'round', round: m.round });
|
|
@@ -370,12 +407,16 @@ export class NetRoom {
|
|
|
370
407
|
return;
|
|
371
408
|
}
|
|
372
409
|
const live = this.live();
|
|
373
|
-
const refuse = (code, message) => {
|
|
410
|
+
const refuse = (code, message, extra = {}) => {
|
|
374
411
|
this.stats.refused += 1;
|
|
375
|
-
this.error(c, code, message);
|
|
412
|
+
this.error(c, code, message, extra);
|
|
376
413
|
this.clients.delete(c.id);
|
|
377
414
|
try { c.conn.close(1008, code); } catch { /* gone */ }
|
|
378
415
|
};
|
|
416
|
+
// The owner closed this room, or held this player out of it (section 15): refused, with when it ends.
|
|
417
|
+
if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
|
|
418
|
+
const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
|
|
419
|
+
if (ban) return refuse('kicked', ban.message, { until: ban.until });
|
|
379
420
|
if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
|
|
380
421
|
if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
|
|
381
422
|
const now = this.now();
|
|
@@ -385,13 +426,14 @@ export class NetRoom {
|
|
|
385
426
|
c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
|
|
386
427
|
c.typed = typeof m.name === 'string' ? m.name.replace(/\s+/g, ' ').trim().slice(0, 24) : '';
|
|
387
428
|
// The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
|
|
388
|
-
if (Number.isInteger(m.max) && !this.seats.size && !live.length) this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max));
|
|
429
|
+
if (Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
|
|
389
430
|
this.reapSeats(now);
|
|
390
431
|
let full = false;
|
|
391
432
|
if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
|
|
392
433
|
if (c.seat === null) c.name = c.typed || (c.want === 'screen' ? 'Screen' : 'Watcher');
|
|
393
434
|
c.helloed = true;
|
|
394
435
|
c.joinedAt = now;
|
|
436
|
+
if (!this.openedAt) this.openedAt = now;
|
|
395
437
|
this.emptySince = 0;
|
|
396
438
|
this.lastJoinAt = now;
|
|
397
439
|
|
|
@@ -426,6 +468,7 @@ export class NetRoom {
|
|
|
426
468
|
round: this.lastRound, roster: this.lastRoster, snap: this.lastSnap, state: this.stateObject(),
|
|
427
469
|
...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
|
|
428
470
|
...(full ? { full: true } : {}),
|
|
471
|
+
...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
|
|
429
472
|
});
|
|
430
473
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
431
474
|
if (deposed) {
|
|
@@ -593,12 +636,13 @@ export class NetRoom {
|
|
|
593
636
|
this.preferHost = null;
|
|
594
637
|
if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
|
|
595
638
|
}
|
|
639
|
+
if (this.regate && now >= this.regate.until) this.applyRegate(now);
|
|
596
640
|
this.reapSeats(now);
|
|
597
641
|
this.seatWaiting();
|
|
598
642
|
if (!this.live().length && this.emptySince && now - this.emptySince > this.forgetMs) {
|
|
599
643
|
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
600
644
|
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
|
|
601
|
-
this.emptySince = 0;
|
|
645
|
+
this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
|
|
602
646
|
this.seatsDirty = false; this.persistDirty = false;
|
|
603
647
|
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
604
648
|
}
|
|
@@ -621,7 +665,7 @@ export class NetRoom {
|
|
|
621
665
|
|
|
622
666
|
saved(now = this.now()) {
|
|
623
667
|
return {
|
|
624
|
-
v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null,
|
|
668
|
+
v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
|
|
625
669
|
seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name]),
|
|
626
670
|
ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
|
|
627
671
|
};
|
|
@@ -636,6 +680,7 @@ export class NetRoom {
|
|
|
636
680
|
const now = this.now();
|
|
637
681
|
if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
|
|
638
682
|
if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
683
|
+
if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
|
|
639
684
|
for (const [seat, token, name] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false });
|
|
640
685
|
this.lastCkpt = saved.ckpt ?? null;
|
|
641
686
|
this.lastRound = saved.round ?? null;
|
|
@@ -652,6 +697,251 @@ export class NetRoom {
|
|
|
652
697
|
return true;
|
|
653
698
|
}
|
|
654
699
|
|
|
700
|
+
/* ------------------------------------------------------------ the owner's controls (section 15) */
|
|
701
|
+
|
|
702
|
+
/** A live client by socket id, else the present client in a seat. */
|
|
703
|
+
findClient({ id = null, seat = null } = {}) {
|
|
704
|
+
if (typeof id === 'string' && id) { const c = this.clients.get(id); if (c && c.helloed) return c; }
|
|
705
|
+
if (Number.isInteger(seat)) return this.live().find((c) => c.seat === seat) ?? null;
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** The hold that keeps this client out, if any: by its seat token, its browser's room key, its account, or (only when the owner asked) its address. */
|
|
710
|
+
banOf(c, token = c.token) {
|
|
711
|
+
const now = this.now();
|
|
712
|
+
return this.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip))) ?? null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
muteOf(c) {
|
|
716
|
+
const now = this.now();
|
|
717
|
+
return this.mutes.find((m) => m.until > now && ((m.token && m.token === c.token) || (m.browser && m.browser === c.browser) || (m.player && m.player === c.player))) ?? null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
liveAnnouncement() {
|
|
721
|
+
const a = this.announcement;
|
|
722
|
+
return a && a.until > this.now() ? a : null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** What a watching shell is told on arrival: the room is closed, or its browser is held out or muted. */
|
|
726
|
+
heldNotice(conn) {
|
|
727
|
+
const now = this.now();
|
|
728
|
+
if (this.closedUntil > now) return { t: 'closed', room: this.code, until: this.closedUntil, message: this.closedWhy || 'The studio closed this room.' };
|
|
729
|
+
const probe = { token: null, browser: conn.browser ?? null, player: conn.player ?? null, ip: null };
|
|
730
|
+
if (!probe.browser && !probe.player) return null;
|
|
731
|
+
const ban = this.banOf(probe, null);
|
|
732
|
+
if (ban) return { t: 'kicked', room: this.code, until: ban.until, message: ban.message };
|
|
733
|
+
const mute = this.muteOf(probe);
|
|
734
|
+
return mute ? { t: 'muted', room: this.code, until: mute.until } : null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Tell the watching shells of one browser (or account) something meant for that player alone. */
|
|
738
|
+
tellBrowser(who, msg) {
|
|
739
|
+
const text = JSON.stringify(msg);
|
|
740
|
+
for (const w of this.watchers) if ((who.browser && w.browser === who.browser) || (who.player && w.player === who.player)) this.sendText(w, text);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* One control, already verified by the caller (the Table checks the owner's signature). Returns what happened:
|
|
745
|
+
* `{ ok: true, ... }`, or `{ ok: false, error }` when there is nobody to act on.
|
|
746
|
+
*/
|
|
747
|
+
control(op, a = {}) {
|
|
748
|
+
const now = this.now();
|
|
749
|
+
const minutes = Math.max(1, Math.min(CONTROL_LIMITS.minutes, Math.floor(Number(a.minutes) || 10)));
|
|
750
|
+
this.bans = this.bans.filter((b) => b.until > now);
|
|
751
|
+
this.mutes = this.mutes.filter((m) => m.until > now);
|
|
752
|
+
switch (op) {
|
|
753
|
+
case 'kick': {
|
|
754
|
+
const target = this.findClient(a);
|
|
755
|
+
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
756
|
+
const until = now + minutes * 60_000;
|
|
757
|
+
// The message is the studio's words; `until` says when (the shell says it in the player's own time).
|
|
758
|
+
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
|
|
759
|
+
const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, name: target.name, seat: target.seat, until, at: now, message };
|
|
760
|
+
this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
|
|
761
|
+
// Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
|
|
762
|
+
const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
|
|
763
|
+
for (const o of out) {
|
|
764
|
+
const seat = o.seat;
|
|
765
|
+
const token = o.token;
|
|
766
|
+
this.error(o, 'kicked', message, { until });
|
|
767
|
+
this.onClose(o, 'kicked');
|
|
768
|
+
// The seat is free for somebody else at once, and its token resumes nothing.
|
|
769
|
+
const s = seat === null ? null : this.seats.get(seat);
|
|
770
|
+
if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
771
|
+
try { o.conn.close(1008, 'kicked'); } catch { /* gone */ }
|
|
772
|
+
}
|
|
773
|
+
this.stats.ownerKicks = (this.stats.ownerKicks ?? 0) + 1;
|
|
774
|
+
this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
|
|
775
|
+
this.persist(now);
|
|
776
|
+
this.tellWatchers();
|
|
777
|
+
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
|
|
778
|
+
}
|
|
779
|
+
case 'mute': {
|
|
780
|
+
const target = this.findClient(a);
|
|
781
|
+
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
782
|
+
const off = a.off === true;
|
|
783
|
+
const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
|
|
784
|
+
this.mutes = this.mutes.filter((m) => !same(m));
|
|
785
|
+
const until = off ? 0 : now + minutes * 60_000;
|
|
786
|
+
if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
|
|
787
|
+
const note = { t: 'mute', id: target.id, seat: target.seat, until };
|
|
788
|
+
for (const o of this.live()) this.send(o, note);
|
|
789
|
+
this.tellBrowser(target, { t: 'muted', room: this.code, until });
|
|
790
|
+
this.tellWatchers();
|
|
791
|
+
return { ok: true, op, name: target.name, seat: target.seat, until };
|
|
792
|
+
}
|
|
793
|
+
case 'announce': {
|
|
794
|
+
const text = oneLine(a.text, CONTROL_LIMITS.announce);
|
|
795
|
+
const prev = this.announcement;
|
|
796
|
+
if (!text) {
|
|
797
|
+
this.announcement = null;
|
|
798
|
+
if (prev) { const clear = JSON.stringify({ t: 'announce', id: prev.id, text: null }); for (const o of this.live()) this.sendText(o.conn, clear); for (const w of this.watchers) this.sendText(w, clear); }
|
|
799
|
+
this.tellWatchers();
|
|
800
|
+
return { ok: true, op, cleared: Boolean(prev) };
|
|
801
|
+
}
|
|
802
|
+
const seconds = Math.max(5, Math.min(CONTROL_LIMITS.announceSeconds, Math.floor(Number(a.seconds) || 30)));
|
|
803
|
+
this.announcement = { id: typeof a.id === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(a.id) ? a.id : randomId(6), text, at: now, until: now + seconds * 1000, from: 'studio' };
|
|
804
|
+
const msg = JSON.stringify({ t: 'announce', ...this.announcement });
|
|
805
|
+
for (const o of this.live()) this.sendText(o.conn, msg);
|
|
806
|
+
for (const w of this.watchers) this.sendText(w, msg);
|
|
807
|
+
this.tellWatchers();
|
|
808
|
+
return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
|
|
809
|
+
}
|
|
810
|
+
case 'close': {
|
|
811
|
+
if (a.reopen === true) { this.closedUntil = 0; this.closedWhy = ''; this.tellWatchers(); return { ok: true, op, reopened: true }; }
|
|
812
|
+
// `seconds` (10 s to a day) is a short close: everyone comes back through the game's door (a launch state change).
|
|
813
|
+
const seconds = Math.floor(Number(a.seconds));
|
|
814
|
+
const until = now + (seconds >= 10 ? Math.min(seconds, CONTROL_LIMITS.minutes * 60) * 1000 : minutes * 60_000);
|
|
815
|
+
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio closed this room. Thanks for playing!';
|
|
816
|
+
this.closedUntil = until;
|
|
817
|
+
this.closedWhy = message;
|
|
818
|
+
const people = this.live().length;
|
|
819
|
+
for (const c of [...this.clients.values()]) {
|
|
820
|
+
if (c.helloed) this.error(c, 'room-closed', message, { until });
|
|
821
|
+
this.clients.delete(c.id);
|
|
822
|
+
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
823
|
+
}
|
|
824
|
+
// Everything the room held goes, as when an empty room forgets (section 4).
|
|
825
|
+
this.hostId = null;
|
|
826
|
+
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
827
|
+
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
|
|
828
|
+
this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
|
|
829
|
+
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
830
|
+
const msg = JSON.stringify({ t: 'closed', room: this.code, until, message });
|
|
831
|
+
for (const w of this.watchers) this.sendText(w, msg);
|
|
832
|
+
this.tellWatchers();
|
|
833
|
+
return { ok: true, op, people, until };
|
|
834
|
+
}
|
|
835
|
+
case 'regate': {
|
|
836
|
+
// A game's launch state changed (NETPLAY.md section 15): who may play is now narrower (`allow`: ticket kinds o,
|
|
837
|
+
// i, p), or anyone again (`allow` absent: a pending change is cancelled). The current round finishes first.
|
|
838
|
+
if (!Array.isArray(a.allow)) { const had = Boolean(this.regate); this.regate = null; this.tellWatchers(); return { ok: true, op, cancelled: had }; }
|
|
839
|
+
const allow = a.allow.filter((k) => ['o', 'i', 'p'].includes(k));
|
|
840
|
+
const leaving = this.live().filter((c) => !this.allowedAfter(c, allow)).length;
|
|
841
|
+
if (!leaving) { this.regate = null; this.tellWatchers(); return { ok: true, op, leaving: 0 }; }
|
|
842
|
+
const r = this.lastRound;
|
|
843
|
+
const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
|
|
844
|
+
const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
|
|
845
|
+
this.regate = { allow, roundN: playing ? Number(r.n) : null, until, at: now, message: oneLine(a.message, CONTROL_LIMITS.message) || 'Thanks for playing!' };
|
|
846
|
+
const notice = oneLine(a.notice, CONTROL_LIMITS.announce);
|
|
847
|
+
if (notice) this.control('announce', { text: notice, seconds: Math.ceil((until - now) / 1000) + 5 });
|
|
848
|
+
this.tellWatchers();
|
|
849
|
+
return { ok: true, op, leaving, until, afterRound: playing ? Number(r.n) : null };
|
|
850
|
+
}
|
|
851
|
+
case 'seats': {
|
|
852
|
+
const n = Math.floor(Number(a.max));
|
|
853
|
+
if (!(n >= 1)) return { ok: false, error: 'max', message: 'seats is a number of at least 1' };
|
|
854
|
+
this.setSeats(n);
|
|
855
|
+
this.tellWatchers();
|
|
856
|
+
return { ok: true, op, max: this.maxPlayers };
|
|
857
|
+
}
|
|
858
|
+
default:
|
|
859
|
+
return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Whether a client may stay once the room re-gates: one of its ticket's holders (o, i-…, p-…) is of an allowed kind. */
|
|
864
|
+
allowedAfter(c, allow) {
|
|
865
|
+
return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p')));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** The launch change, now: everyone it leaves out is sent out of the room with the studio's words; the rest play on. */
|
|
869
|
+
applyRegate(now = this.now()) {
|
|
870
|
+
const g = this.regate;
|
|
871
|
+
if (!g) return 0;
|
|
872
|
+
this.regate = null;
|
|
873
|
+
// The holder of the room's storage keeps the owner's state (the Table writes it on its next tick).
|
|
874
|
+
this.officeDirty = true;
|
|
875
|
+
const out = this.live().filter((c) => !this.allowedAfter(c, g.allow));
|
|
876
|
+
for (const c of out) {
|
|
877
|
+
const seat = c.seat;
|
|
878
|
+
const token = c.token;
|
|
879
|
+
this.error(c, 'room-closed', g.message);
|
|
880
|
+
this.onClose(c, 'regated');
|
|
881
|
+
const s = seat === null ? null : this.seats.get(seat);
|
|
882
|
+
if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
883
|
+
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
884
|
+
this.tellBrowser(c, { t: 'closed', room: this.code, message: g.message });
|
|
885
|
+
}
|
|
886
|
+
this.persist(now);
|
|
887
|
+
this.tellWatchers();
|
|
888
|
+
return out.length;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The room's seats, changed while it runs (the owner's "max players per room"). Players already seated above the
|
|
893
|
+
* new number keep their seat until they leave; nobody new is seated there. Never above the room's own cap.
|
|
894
|
+
*/
|
|
895
|
+
setSeats(n, perIp = null) {
|
|
896
|
+
this.seatCap = Math.max(1, Math.floor(n));
|
|
897
|
+
this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.askedMax ?? this.seatCap));
|
|
898
|
+
if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** The owner's holds, the banner and a closed door: kept apart from the room's play (they outlive an empty room). */
|
|
902
|
+
officeSaved() {
|
|
903
|
+
const now = this.now();
|
|
904
|
+
return {
|
|
905
|
+
bans: this.bans.filter((b) => b.until > now), mutes: this.mutes.filter((m) => m.until > now),
|
|
906
|
+
announcement: this.liveAnnouncement(), closedUntil: this.closedUntil > now ? this.closedUntil : 0, closedWhy: this.closedWhy,
|
|
907
|
+
regate: this.regate,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
restoreOffice(o) {
|
|
912
|
+
if (!o || typeof o !== 'object') return;
|
|
913
|
+
const now = this.now();
|
|
914
|
+
this.bans = (Array.isArray(o.bans) ? o.bans : []).filter((b) => b && b.until > now).slice(-CONTROL_LIMITS.bans);
|
|
915
|
+
this.mutes = (Array.isArray(o.mutes) ? o.mutes : []).filter((m) => m && m.until > now).slice(-CONTROL_LIMITS.mutes);
|
|
916
|
+
this.announcement = o.announcement && o.announcement.until > now ? o.announcement : null;
|
|
917
|
+
this.closedUntil = Number(o.closedUntil) > now ? Number(o.closedUntil) : 0;
|
|
918
|
+
this.closedWhy = this.closedUntil ? String(o.closedWhy ?? '') : '';
|
|
919
|
+
// A launch change still waiting comes back; one long past is over (whoever it was for has gone).
|
|
920
|
+
this.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
|
|
924
|
+
officeFacts() {
|
|
925
|
+
const f = this.facts();
|
|
926
|
+
const now = this.now();
|
|
927
|
+
const byId = new Map(this.live().map((c) => [c.id, c]));
|
|
928
|
+
return {
|
|
929
|
+
...f,
|
|
930
|
+
office: {
|
|
931
|
+
openedAt: this.openedAt || null,
|
|
932
|
+
closedUntil: this.closedUntil > now ? this.closedUntil : null,
|
|
933
|
+
regate: this.regate ? { allow: this.regate.allow, until: this.regate.until, afterRound: this.regate.roundN } : null,
|
|
934
|
+
clients: f.clients.map((p) => {
|
|
935
|
+
const c = byId.get(p.id);
|
|
936
|
+
const mute = c ? this.muteOf(c) : null;
|
|
937
|
+
return { ...p, joinedAt: c?.joinedAt ?? null, lastSeen: c?.lastSeen ?? null, browser: browserTag(c?.browser), player: c?.player ?? null, via: c?.via ?? null, mutedUntil: mute ? mute.until : null };
|
|
938
|
+
}),
|
|
939
|
+
bans: this.bans.filter((b) => b.until > now).map((b) => ({ name: b.name, seat: b.seat, until: b.until, at: b.at, address: Boolean(b.ip), browser: browserTag(b.browser) })),
|
|
940
|
+
mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
655
945
|
/* ------------------------------------------------------------ the shell's view */
|
|
656
946
|
|
|
657
947
|
facts() {
|
|
@@ -662,6 +952,9 @@ export class NetRoom {
|
|
|
662
952
|
return {
|
|
663
953
|
t: 'net', v: NET_VERSION, room: this.code, st: now,
|
|
664
954
|
host: this.hostRef(),
|
|
955
|
+
openedAt: this.openedAt || null,
|
|
956
|
+
announce: this.liveAnnouncement(),
|
|
957
|
+
...(this.closedUntil > now ? { closedUntil: this.closedUntil } : {}),
|
|
665
958
|
clients: live.map((c) => ({ ...this.peer(c), hidden: c.hidden, waiting: c.waiting })),
|
|
666
959
|
round: this.lastRound,
|
|
667
960
|
roster: this.lastRoster,
|
package/worker/stats-page.mjs
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
|
|
11
11
|
import { CODEX_SCRIPT } from './codex-script.mjs';
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Where a sign-in may send the owner next: the stats, one game's codex, the back office, an ask waiting for the
|
|
15
|
+
* owner's tap, or one of the studio's games (a private game's own secret road for the owner's phone).
|
|
16
|
+
*/
|
|
17
|
+
const NEXT = /^\/(?:_studio\/(?:stats|office|codex\/[a-z0-9][a-z0-9-]{0,39}\/|confirm\/ask_[a-f0-9]{16})|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
|
|
15
18
|
const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
|
|
19
|
+
const signinWord = (to) => (to.startsWith('/_studio/codex/') ? 'Open the codex' : to === '/_studio/office' ? 'Open the office' : to.startsWith('/_studio/confirm/') ? 'Sign in and see what your AI asks' : to.startsWith('/_studio/stats') ? 'Open the stats' : 'Sign in and play');
|
|
16
20
|
let scriptHash = null;
|
|
17
21
|
async function codexScriptHash() {
|
|
18
22
|
if (!scriptHash) {
|
|
@@ -99,7 +103,7 @@ function statsBody(cat, s, url) {
|
|
|
99
103
|
const videoRows = s.videos.map((e) => `<tr><td><a href="${esc(q({ video: e.slug, game: null, song: null }))}">${esc(e.title)}</a></td><td>${fmt(e.visits)}</td><td>${fmt(e.views)}</td></tr>`).join('');
|
|
100
104
|
const refRows = s.referrers.map((r) => `<tr><td>${esc(r.from)} <span class="kind">${esc(KIND_WORD[r.kind] ?? r.kind)}</span></td><td>${fmt(r.visits)}</td><td>${fmt(r.plays)}</td></tr>`).join('');
|
|
101
105
|
return `<header><h1>${esc(cat.studio?.name ?? 'Studio')} stats${s.only ? ` <span class="dim">· ${esc(s.only.kind)} ${esc(s.only.id)}</span>` : ''}</h1>
|
|
102
|
-
<form method="post" action="/_studio/signout"><button class="ghost" type="submit">Sign out</button></form></header>
|
|
106
|
+
<span><a href="/_studio/office">Office</a> <form method="post" action="/_studio/signout"><button class="ghost" type="submit">Sign out</button></form></span></header>
|
|
103
107
|
<p class="dim">${esc(s.range.from)} to ${esc(s.range.to)} (UTC)${s.only ? ` · <a href="${esc(q({ game: null, song: null, video: null }))}">whole studio</a>` : ''}</p>
|
|
104
108
|
<nav class="ranges">${ranges}</nav>
|
|
105
109
|
<section class="tiles">
|
|
@@ -132,18 +136,23 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
|
|
|
132
136
|
if (request.method === 'GET') {
|
|
133
137
|
// A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
|
|
134
138
|
return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
|
|
135
|
-
<p class="dim">
|
|
136
|
-
<form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${nextOf(url)
|
|
139
|
+
<p class="dim">Sign this browser in as the studio's owner: the private stats and office, and the owner's tools in your own games. The link works once.</p>
|
|
140
|
+
<form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${signinWord(nextOf(url))}</button></form>`);
|
|
137
141
|
}
|
|
138
142
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
|
|
139
143
|
const session = await spendSignin(env, key);
|
|
140
144
|
if (!session) return page('Link used or expired', `<h1>That link has been used or has expired</h1><p class="note">A sign-in link works once, for 30 minutes. Ask your AI for a new one: <code>npx --no-install homie-studio stats link</code>.</p>`, 403);
|
|
141
|
-
|
|
145
|
+
// The session lives at / (0.13.0): the owner is recognised in their own games, never readable by a page or a game.
|
|
146
|
+
return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
|
|
142
147
|
}
|
|
143
148
|
if (path === '/_studio/signout') {
|
|
144
149
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
|
|
145
150
|
await endSession(env, request);
|
|
146
|
-
|
|
151
|
+
const headers = new Headers({ location: '/_studio/stats', 'cache-control': 'no-store' });
|
|
152
|
+
// Both places a session cookie has lived (/ from 0.13.0, /_studio/ before).
|
|
153
|
+
headers.append('set-cookie', `${OWNER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`);
|
|
154
|
+
headers.append('set-cookie', `${OWNER_COOKIE}=; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`);
|
|
155
|
+
return new Response(null, { status: 303, headers });
|
|
147
156
|
}
|
|
148
157
|
if (path === '/_studio/stats' || path === '/_studio/stats/') {
|
|
149
158
|
if (request.method !== 'GET') return page('Not allowed', '<h1>Not allowed</h1>', 405);
|
package/worker/stats.mjs
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
* `homie-studio stats` prints them (a ten-minute read key, minted and dropped by the CLI)
|
|
26
26
|
* `homie-studio stats key` a read key for the Homie MCP tool `studio_stats` (1 hour by default)
|
|
27
27
|
* `homie-studio stats link` a one-time sign-in link: /_studio/stats in the owner's own browser
|
|
28
|
+
* `homie-studio office key` a back-office key (it reads these too): worker/office.mjs
|
|
28
29
|
* A key is 24 random bytes; D1 keeps only its SHA-256. The page's session cookie is the owner's alone
|
|
29
|
-
* (HttpOnly, SameSite=Lax
|
|
30
|
+
* (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
|
|
31
|
+
* page or a game). There is no other way in.
|
|
30
32
|
*
|
|
31
33
|
* FREE PLAN: D1 allows 100,000 row writes and 5 million row reads a day. A visit, a play, a room and a
|
|
32
34
|
* song or video start are one write each; a finished round is three (its row in `rounds`, the round and the
|
|
@@ -134,30 +136,54 @@ export async function countVisit(request, env, ctx, subject, metric = 'visit') {
|
|
|
134
136
|
|
|
135
137
|
/* ------------------------------------------------------------------ the owner's keys */
|
|
136
138
|
|
|
137
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Whether a presented key (Bearer header) or the owner's page session is valid, and which: `read` (a stats read
|
|
141
|
+
* key), `office` (a back-office key: the stats, the live rooms, and asking for the owner's controls; worker/office.mjs)
|
|
142
|
+
* or `session` (the owner's signed-in browser). A key's kind is fixed when it is minted; an office key also reads
|
|
143
|
+
* the stats.
|
|
144
|
+
*/
|
|
138
145
|
export async function ownerAllowed(request, env, { kinds = ['read', 'session'] } = {}) {
|
|
139
146
|
if (!env?.DB) return false;
|
|
140
147
|
const auth = request.headers.get('authorization') ?? '';
|
|
141
148
|
const bearer = /^Bearer\s+(\S+)$/i.exec(auth)?.[1] ?? null;
|
|
142
|
-
const cookie = cookieValue(request, OWNER_COOKIE);
|
|
143
149
|
const tries = [];
|
|
144
|
-
if (bearer && KEY.test(bearer) && kinds.includes('read')) tries.push(['read', bearer]);
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
if (bearer && KEY.test(bearer) && (kinds.includes('read') || kinds.includes('office'))) tries.push([['read', 'office'].filter((k) => kinds.includes(k)), bearer]);
|
|
151
|
+
// The session cookie lives at / from 0.13.0 (the owner is recognised in their own game) and at /_studio/ before
|
|
152
|
+
// it: a browser may send both, so each is tried.
|
|
153
|
+
if (kinds.includes('session')) for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) tries.push([['session'], cookie]);
|
|
154
|
+
for (const [allowed, value] of tries) {
|
|
147
155
|
try {
|
|
148
156
|
const row = await env.DB.prepare('SELECT kind, expires_at FROM stats_keys WHERE hash = ?1').bind(await sha256(value)).first();
|
|
149
|
-
if (row && row.kind
|
|
157
|
+
if (row && allowed.includes(row.kind) && Number(row.expires_at) > Date.now()) return row.kind;
|
|
150
158
|
} catch { return false; }
|
|
151
159
|
}
|
|
152
160
|
return false;
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
/** The owner's valid page session value this request carries, or null (to carry an older /_studio/ cookie to /). */
|
|
164
|
+
export async function ownerSession(request, env) {
|
|
165
|
+
if (!env?.DB) return null;
|
|
166
|
+
for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) {
|
|
167
|
+
try {
|
|
168
|
+
const row = await env.DB.prepare('SELECT kind, expires_at FROM stats_keys WHERE hash = ?1').bind(await sha256(cookie)).first();
|
|
169
|
+
if (row && row.kind === 'session' && Number(row.expires_at) > Date.now()) return cookie;
|
|
170
|
+
} catch { return null; }
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
export function cookieValue(request, name) {
|
|
176
|
+
return cookieValues(request, name)[0] ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every value a request carries for one cookie name (the same name can arrive from two paths). */
|
|
180
|
+
export function cookieValues(request, name) {
|
|
181
|
+
const out = [];
|
|
156
182
|
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
157
183
|
const [k, ...v] = part.trim().split('=');
|
|
158
|
-
if (k === name)
|
|
184
|
+
if (k === name) out.push(v.join('='));
|
|
159
185
|
}
|
|
160
|
-
return
|
|
186
|
+
return out;
|
|
161
187
|
}
|
|
162
188
|
|
|
163
189
|
/** A one-time sign-in key turns into a 30-day page session (the key is spent). Returns the session value or null. */
|
|
@@ -178,9 +204,10 @@ export async function spendSignin(env, key) {
|
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
export async function endSession(env, request) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
207
|
+
if (!env?.DB) return;
|
|
208
|
+
for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) {
|
|
209
|
+
try { await env.DB.prepare("DELETE FROM stats_keys WHERE hash = ?1 AND kind = 'session'").bind(await sha256(cookie)).run(); } catch { /* gone */ }
|
|
210
|
+
}
|
|
184
211
|
}
|
|
185
212
|
|
|
186
213
|
/* ------------------------------------------------------------------ reading */
|
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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.13.0';
|