@homie-rocks/studio 0.3.0 → 0.6.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/worker/pages.mjs CHANGED
@@ -27,7 +27,23 @@ header.top { display: flex; align-items: baseline; justify-content: space-betwee
27
27
  .hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
28
28
  .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
29
29
  footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
30
- @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } }
30
+ h2.section { margin: 44px 0 0; font-size: 15px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
31
+ h2.section a { text-decoration: none; }
32
+ .card .kind { color: var(--accent); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
33
+ .thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; background: #000; display: block; }
34
+ .thumb.square { aspect-ratio: 1; }
35
+ .player { margin: 8px 0 20px; }
36
+ .player audio { width: 100%; }
37
+ .player video { width: 100%; max-height: 78vh; border-radius: 14px; background: #000; display: block; }
38
+ .player video.vertical { max-width: 420px; margin: 0 auto; }
39
+ .facts { color: var(--dim); font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; margin: 0 0 18px; }
40
+ .rights { color: var(--dim); font-size: 13px; border-top: 1px solid var(--line); padding-top: 14px; margin-top: 24px; max-width: 70ch; }
41
+ .lyrics { white-space: pre-wrap; font-size: 17px; line-height: 1.6; max-width: 60ch; }
42
+ .files { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; max-width: 560px; }
43
+ .files a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; text-decoration: none; }
44
+ .files span { color: var(--dim); font-size: 13px; }
45
+ .cover { width: min(320px, 100%); aspect-ratio: 1; object-fit: cover; border-radius: 16px; float: right; margin: 0 0 16px 24px; }
46
+ @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } .cover { float: none; margin: 0 0 16px; width: 100%; } }
31
47
  `;
32
48
 
33
49
  function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
@@ -37,9 +53,39 @@ function page(title, body, { extraHead = '', viewport = 'width=device-width, ini
37
53
  <body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
38
54
  }
39
55
 
56
+ const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
57
+ const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
58
+ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
59
+
60
+ function songCard(e) {
61
+ const cover = fileOf(e, 'cover');
62
+ return `
63
+ <article class="card">
64
+ ${cover ? `<img class="thumb square" src="${esc(cover.url)}" alt="" loading="lazy">` : ''}
65
+ <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Music')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
66
+ <h2>${esc(e.title)}</h2>
67
+ <p>${esc(e.blurb)}</p>
68
+ <div class="row"><a class="btn" href="/music/${esc(e.slug)}/">Listen</a></div>
69
+ </article>`;
70
+ }
71
+
72
+ function videoCard(e) {
73
+ const poster = fileOf(e, 'poster');
74
+ return `
75
+ <article class="card">
76
+ ${poster ? `<img class="thumb" src="${esc(poster.url)}" alt="" loading="lazy">` : ''}
77
+ <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Video')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
78
+ <h2>${esc(e.title)}</h2>
79
+ <p>${esc(e.blurb)}</p>
80
+ <div class="row"><a class="btn" href="/videos/${esc(e.slug)}/">Watch</a></div>
81
+ </article>`;
82
+ }
83
+
40
84
  export function homePage(cat, live = {}) {
41
85
  const name = cat.studio?.name ?? 'Studio';
42
86
  const games = cat.games ?? [];
87
+ const songs = cat.songs ?? [];
88
+ const videos = cat.videos ?? [];
43
89
  const cards = games.map((g) => `
44
90
  <article class="card">
45
91
  <h2>${esc(g.name)}</h2>
@@ -48,12 +94,94 @@ export function homePage(cat, live = {}) {
48
94
  <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play</a><a class="btn ghost" href="/${esc(g.id)}/">About</a></div>
49
95
  </article>`).join('');
50
96
  return page(name, `<div class="wrap">
51
- <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">Press Play and you are in a live room.</span></header>
52
- ${games.length ? `<section class="grid">${cards}</section>` : '<p class="tag" style="margin-top:28px">No games published yet.</p>'}
97
+ <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">${games.length ? 'Press Play and you are in a live room.' : 'Games, music and videos.'}</span></header>
98
+ ${games.length ? `${songs.length || videos.length ? '<h2 class="section">Games</h2>' : ''}<section class="grid">${cards}</section>` : (songs.length || videos.length ? '' : '<p class="tag" style="margin-top:28px">Nothing published yet.</p>')}
99
+ ${videos.length ? `<h2 class="section"><a href="/videos/">Videos</a></h2><section class="grid">${videos.map(videoCard).join('')}</section>` : ''}
100
+ ${songs.length ? `<h2 class="section"><a href="/music/">Music</a></h2><section class="grid">${songs.map(songCard).join('')}</section>` : ''}
53
101
  <footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
54
102
  </div>`);
55
103
  }
56
104
 
105
+ /** /music/ and /videos/: every published entry of one kind. */
106
+ export function mediaIndexPage(cat, kind) {
107
+ const name = cat.studio?.name ?? 'Studio';
108
+ const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
109
+ const title = kind === 'music' ? 'Music' : 'Videos';
110
+ return page(`${title} · ${name}`, `<div class="wrap">
111
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${esc(title)}</span></header>
112
+ ${list.length ? `<section class="grid">${list.map(kind === 'music' ? songCard : videoCard).join('')}</section>` : `<p class="tag" style="margin-top:28px">No ${kind === 'music' ? 'music' : 'videos'} published yet.</p>`}
113
+ </div>`);
114
+ }
115
+
116
+ function rightsLine(e) {
117
+ const bits = [];
118
+ if (e.credits) bits.push(esc(e.credits));
119
+ const r = e.rights;
120
+ if (r && typeof r === 'object') {
121
+ if (r.commercial === true) bits.push(`Licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
122
+ else if (r.commercial === false) bits.push(`Not licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
123
+ if (r.attribution) bits.push(esc(r.attribution));
124
+ }
125
+ if (e.honesty) bits.push(esc(e.honesty));
126
+ return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
127
+ }
128
+
129
+ /**
130
+ * The page's one stats beacon: when its player first starts, one POST to /api/stats/beat (a counter, nothing about
131
+ * the listener). worker/stats.mjs says what is counted.
132
+ */
133
+ const playedBeacon = (kind, slug) => `<script>(function(){var m=document.querySelector(${JSON.stringify(kind === 'song' ? 'audio' : 'video[data-main]')});if(!m||!navigator.sendBeacon)return;m.addEventListener('play',function(){try{navigator.sendBeacon('/api/stats/beat',${JSON.stringify(JSON.stringify({ k: kind, s: slug })).replace(/</g, '\\u003c')});}catch(e){}},{once:true});}());</script>`;
134
+
135
+ const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta property="${esc(k)}" content="${esc(v)}">`).join('');
136
+
137
+ /** /music/<slug>/: the player, the facts, the words, the loops and stems another game may use. */
138
+ export function songPage(cat, e, origin = '') {
139
+ const name = cat.studio?.name ?? 'Studio';
140
+ const audio = fileOf(e, 'audio');
141
+ const cover = fileOf(e, 'cover');
142
+ const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
143
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
144
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
145
+ return page(`${e.title} · ${name}`, `<div class="wrap">
146
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/music/">Music</a></header>
147
+ <section class="hero">
148
+ ${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
149
+ <h1>${esc(e.title)}</h1>
150
+ ${e.blurb ? `<p>${esc(e.blurb)}</p>` : ''}
151
+ <div class="player"><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
152
+ <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null, e.bpm ? `${e.bpm} BPM` : null, e.key].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${game ? `<a href="/${esc(game.id)}/">${esc(game.name)}</a>` : ''}</div>
153
+ </section>
154
+ ${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
155
+ ${extras.length ? `<h2 class="section">Loops and stems</h2><ul class="files">${extras.map((f) => `<li><a href="${esc(f.url)}" download><b>${esc(f.name ?? (f.role === 'loop' ? 'Loop' : 'Stem'))}</b><span>${esc([f.role, f.bars ? `${f.bars} bars` : null, f.bytes ? `${Math.max(1, Math.round(f.bytes / 1024))} KB` : null].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ul>` : ''}
156
+ ${rightsLine(e)}
157
+ </div>${playedBeacon('song', e.slug)}`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
158
+ }
159
+
160
+ /** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
161
+ export function videoPage(cat, e, origin = '') {
162
+ const name = cat.studio?.name ?? 'Studio';
163
+ const video = fileOf(e, 'video');
164
+ const vertical = fileOf(e, 'vertical');
165
+ const poster = fileOf(e, 'poster');
166
+ const captions = fileOf(e, 'captions');
167
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
168
+ const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
169
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
170
+ // A portrait phone gets the 9:16 cut when there is one; everything else the 16:9 cut.
171
+ const pick = vertical ? `<script>(function(){var v=document.querySelector('video[data-main]');if(!v)return;if(matchMedia('(orientation: portrait) and (max-width: 700px)').matches){v.src=${JSON.stringify(vertical.url).replace(/</g, '\u003c')};v.classList.add('vertical');}}());</script>` : '';
172
+ return page(`${e.title} · ${name}`, `<div class="wrap">
173
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/videos/">Videos</a></header>
174
+ <section style="padding-top:28px">
175
+ <div class="player"><video data-main controls playsinline preload="metadata" src="${esc(video.url)}"${poster ? ` poster="${esc(poster.url)}"` : ''}>${captions ? `<track kind="captions" src="${esc(captions.url)}" srclang="en" label="English">` : ''}</video></div>
176
+ <h1 style="font-size:clamp(30px,6vw,56px);margin:0 0 10px;letter-spacing:-.02em">${esc(e.title)}</h1>
177
+ ${e.blurb ? `<p class="tag" style="font-size:17px;max-width:62ch">${esc(e.blurb)}</p>` : ''}
178
+ <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${song ? `<a href="/music/${esc(song.slug)}/">${esc(song.title)}</a>` : ''}</div>
179
+ ${game ? `<div class="row"><a class="btn" href="/${esc(game.id)}/play">Play ${esc(game.name)}</a>${vertical ? `<a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a>` : ''}</div>` : (vertical ? `<div class="row"><a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a></div>` : '')}
180
+ </section>
181
+ ${rightsLine(e)}
182
+ </div>${pick}${playedBeacon('video', e.slug)}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
183
+ }
184
+
57
185
  export function gamePage(cat, g, playing = 0) {
58
186
  const name = cat.studio?.name ?? 'Studio';
59
187
  return page(`${g.name} · ${name}`, `<div class="wrap">
package/worker/room.mjs CHANGED
@@ -23,10 +23,17 @@ export const NET_VERSION = 1;
23
23
  const PALETTE_SIZE = 12;
24
24
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
25
25
 
26
- /** Size caps (bytes of the JSON text as received). */
26
+ /** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
27
27
  export const LIMITS = Object.freeze({
28
28
  hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
29
29
  });
30
+ /**
31
+ * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
32
+ * for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
33
+ * a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
34
+ */
35
+ export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
36
+ export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
30
37
  /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
31
38
  export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8 });
32
39
  /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
@@ -169,7 +176,7 @@ export class NetRoom {
169
176
 
170
177
  allow(c, t, bytes) {
171
178
  // A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
172
- const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : (LIMITS[t] ?? LIMITS.other);
179
+ const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : capOf(t, this.seatCap);
173
180
  if (bytes > cap) { this.stats.oversize += 1; this.error(c, 'too-large', `${t} is ${bytes} B; the cap is ${cap} B`); return false; }
174
181
  const now = this.now();
175
182
  const limit = t === 'ev' ? EV_RATES[this.roleOf(c)] : (RATES[t] ?? RATES.other);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Room sizes, shared by the site Worker (worker/index.mjs) and `homie-studio build` (lib/build.mjs).
3
+ *
4
+ * A game names its seats in its netplay manifest (game.json `netplay.maxPlayers`, or `netplay.json` with
5
+ * `maxPlayers` or `players.max`); every room of that game has that many, up to SEAT_MAX (32).
6
+ */
7
+
8
+ /** The most seats one room holds (NETPLAY.md §3). */
9
+ export const SEAT_MAX = 32;
10
+
11
+ /** A game's seats from its catalogue row: 1 to SEAT_MAX, 8 when it names none. */
12
+ export const seatsOf = (meta) => Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(meta?.players?.max)) || 8));
13
+
14
+ /**
15
+ * Sockets one client address may hold in one room: 12, or every seat plus four (a big screen or two, and a
16
+ * visitor waiting for a seat) when that is more. A party of thirty phones and a TV on one Wi-Fi (or strangers
17
+ * behind one carrier's shared address) fills a 32-seat room from a single address, and must be let in; an 8-seat
18
+ * game keeps the old 12.
19
+ */
20
+ export const perAddress = (seats) => Math.max(12, seats + 4);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The studio owner's private stats page (/_studio/stats) and its one-time sign-in (/_studio/signin).
3
+ *
4
+ * Only the owner gets in: `homie-studio stats link` (run with the studio's own Cloudflare login) mints a one-time
5
+ * key; opening the link shows a button, and pressing it (a POST from this site) spends the key for a session
6
+ * cookie that lives only under /_studio/ (HttpOnly, SameSite=Lax, Secure on https). A link preview or a crawler
7
+ * that GETs the link spends nothing. Everything is drawn on the server: no script, nothing loaded from anywhere,
8
+ * never cached, never indexed.
9
+ */
10
+ import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
11
+
12
+ const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
13
+ const fmt = (n) => Number(n || 0).toLocaleString('en-US');
14
+ const PRIVATE_HEADERS = {
15
+ 'content-type': 'text/html; charset=utf-8',
16
+ 'cache-control': 'no-store, private',
17
+ 'x-robots-tag': 'noindex, nofollow',
18
+ // same-origin, not no-referrer: a no-referrer page's own form POST carries `Origin: null`, which the sign-in refuses.
19
+ 'referrer-policy': 'same-origin',
20
+ 'x-frame-options': 'DENY',
21
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
22
+ };
23
+
24
+ const CSS = `
25
+ :root { color-scheme: dark; --bg: #07080d; --panel: #10131c; --ink: #eef1f8; --dim: #9aa3b7; --line: rgba(255,255,255,.10); --accent: #ffcf5a; --good: #7dffb0; }
26
+ * { box-sizing: border-box; }
27
+ html, body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
28
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 28px 16px 64px; }
29
+ header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
30
+ h1 { margin: 0; font-size: clamp(24px, 5vw, 36px); letter-spacing: -.02em; }
31
+ h2 { margin: 36px 0 10px; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
32
+ .dim { color: var(--dim); }
33
+ .ranges a, .ranges b { display: inline-block; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); text-decoration: none; color: inherit; margin: 0 4px 6px 0; font-size: 14px; }
34
+ .ranges b { background: var(--accent); color: #1a1405; border-color: var(--accent); }
35
+ .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 20px; }
36
+ .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; }
37
+ .tile b { display: block; font-size: 26px; letter-spacing: -.02em; }
38
+ .tile span { color: var(--dim); font-size: 13px; }
39
+ .scroll { overflow-x: auto; }
40
+ a { color: #ffe08a; }
41
+ table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
42
+ table.wide { min-width: 640px; }
43
+ th, td { text-align: right; padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
44
+ th:first-child, td:first-child { text-align: left; white-space: normal; }
45
+ th { color: var(--dim); font-weight: 600; font-size: 13px; }
46
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 90px; margin-top: 8px; }
47
+ .bars div { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 1px; opacity: .85; }
48
+ .kind { font-size: 12px; color: var(--dim); }
49
+ .live { color: var(--good); }
50
+ form { display: inline; }
51
+ button { font: inherit; border: 0; border-radius: 999px; padding: 12px 22px; font-weight: 800; background: var(--accent); color: #1a1405; cursor: pointer; }
52
+ button.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 600; padding: 6px 14px; }
53
+ p.note { color: var(--dim); font-size: 13px; max-width: 70ch; }
54
+ `;
55
+
56
+ function page(title, body, status = 200, extraHeaders = {}) {
57
+ return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>${CSS}</style></head><body><div class="wrap">${body}</div></body></html>`, { status, headers: { ...PRIVATE_HEADERS, ...extraHeaders } });
59
+ }
60
+
61
+ function locked(cat) {
62
+ return page('Stats · private', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
63
+ <p class="dim">This page is for the studio's owner only.</p>
64
+ <p class="note">To open it, the owner asks their AI in the studio's folder to run <code>npx --no-install homie-studio stats link</code>. That uses the studio's own Cloudflare login, and gives a one-time link for this browser.</p>`, 401);
65
+ }
66
+
67
+ /** A POST from this site's own page: its Origin, or (when a browser sends `Origin: null` or none) Sec-Fetch-Site. */
68
+ const sameOrigin = (request, url) => {
69
+ const origin = request.headers.get('origin');
70
+ if (origin && origin !== 'null') return origin === url.origin;
71
+ return request.headers.get('sec-fetch-site') === 'same-origin';
72
+ };
73
+
74
+ const KIND_WORD = { hub: 'the Homie hub', studio: 'another studio', search: 'search', web: 'the web', link: 'a ?via= link' };
75
+
76
+ function statsBody(cat, s, url) {
77
+ const t = s.totals;
78
+ const q = (extra) => { const p = new URLSearchParams(url.searchParams); for (const [k, v] of Object.entries(extra)) { if (v === null) p.delete(k); else p.set(k, v); } p.delete('from'); p.delete('to'); return `?${p}`; };
79
+ const range = url.searchParams.get('range') ?? (s.range.days === 1 ? '1d' : `${s.range.days}d`);
80
+ const ranges = ['1d', '7d', '30d', '90d'].map((r) => (r === range ? `<b>${r === '1d' ? 'Today' : `${r.replace('d', '')} days`}</b>` : `<a href="${esc(q({ range: r }))}">${r === '1d' ? 'Today' : `${r.replace('d', '')} days`}</a>`)).join('');
81
+ const tile = (n, label, cls = '') => `<div class="tile"><b class="${cls}">${esc(fmt(n))}</b><span>${esc(label)}</span></div>`;
82
+ const peak = Math.max(1, ...s.days.map((d) => d.plays + d.visits));
83
+ const bars = s.days.map((d) => `<div title="${esc(d.day)}: ${esc(d.visits)} visits, ${esc(d.plays)} plays" style="height:${Math.round(((d.plays + d.visits) / peak) * 100)}%"></div>`).join('');
84
+ const gameRows = s.games.map((g) => `<tr><td><a href="${esc(q({ game: g.id, song: null, video: null }))}">${esc(g.name)}</a> <span class="kind">${esc(g.seats ?? '?')} seats</span></td><td>${fmt(g.visits)}</td><td>${fmt(g.plays)}</td><td>${fmt(g.rooms)}</td><td>${fmt(g.rounds)}</td><td>${fmt(g.peopleInRounds)}</td><td>${fmt(g.peakPlayers)}</td><td>${fmt(g.peakInOneRoom)}</td><td class="${g.playingNow ? 'live' : ''}">${fmt(g.playingNow)}</td></tr>`).join('');
85
+ const songRows = s.songs.map((e) => `<tr><td><a href="${esc(q({ song: e.slug, game: null, video: null }))}">${esc(e.title)}</a></td><td>${fmt(e.visits)}</td><td>${fmt(e.plays)}</td></tr>`).join('');
86
+ 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('');
87
+ 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('');
88
+ return `<header><h1>${esc(cat.studio?.name ?? 'Studio')} stats${s.only ? ` <span class="dim">· ${esc(s.only.kind)} ${esc(s.only.id)}</span>` : ''}</h1>
89
+ <form method="post" action="/_studio/signout"><button class="ghost" type="submit">Sign out</button></form></header>
90
+ <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>
91
+ <nav class="ranges">${ranges}</nav>
92
+ <section class="tiles">
93
+ ${tile(t.visits, 'visits')}${tile(t.plays, 'Play presses')}${tile(t.rooms, 'rooms opened')}${tile(t.rounds, 'rounds finished')}
94
+ ${tile(t.peopleInRounds, 'people in finished rounds')}${tile(t.peakPlayers, 'most playing at once')}${tile(t.playingNow, 'playing right now', t.playingNow ? 'live' : '')}
95
+ ${tile(t.songPlays, 'songs played')}${tile(t.videoViews, 'videos watched')}
96
+ </section>
97
+ <h2>Every day</h2><div class="bars" role="img" aria-label="visits and plays per day">${bars}</div>
98
+ <h2>Games</h2><div class="scroll"><table class="wide"><thead><tr><th>Game</th><th>Visits</th><th>Plays</th><th>Rooms</th><th>Rounds</th><th>People in rounds</th><th>Peak</th><th>Peak in a room</th><th>Now</th></tr></thead><tbody>${gameRows || '<tr><td colspan="9" class="dim">No games.</td></tr>'}</tbody></table></div>
99
+ ${s.songs.length ? `<h2>Songs</h2><div class="scroll"><table><thead><tr><th>Song</th><th>Page visits</th><th>Played</th></tr></thead><tbody>${songRows}</tbody></table></div>` : ''}
100
+ ${s.videos.length ? `<h2>Videos</h2><div class="scroll"><table><thead><tr><th>Video</th><th>Page visits</th><th>Watched</th></tr></thead><tbody>${videoRows}</tbody></table></div>` : ''}
101
+ <h2>Where people came from</h2>
102
+ <p class="dim">From homie.rocks: ${fmt(s.crossings.fromHub)} · from other studios: ${fmt(s.crossings.fromStudios)} · from search: ${fmt(s.crossings.fromSearch)} · from the web: ${fmt(s.crossings.fromWeb)} · from ?via= links: ${fmt(s.crossings.fromLinks)}</p>
103
+ <div class="scroll"><table><thead><tr><th>From</th><th>Visits</th><th>Plays</th></tr></thead><tbody>${refRows || '<tr><td colspan="3" class="dim">Everybody came directly, or from this site.</td></tr>'}</tbody></table></div>
104
+ <p class="note">${esc(s.note)} This page is private to the studio's owner and is never cached or indexed.</p>`;
105
+ }
106
+
107
+ /**
108
+ * /_studio/* for the owner, or null when the path is not one of these. `catalogueOf` reads games.json;
109
+ * `studios` names sites that are Homie studios (for the "another studio" crossing).
110
+ */
111
+ export async function ownerRoutes(request, env, url, { catalogueOf }) {
112
+ const path = url.pathname;
113
+ if (!path.startsWith('/_studio/')) return null;
114
+ const cat = await catalogueOf();
115
+ const secure = url.protocol === 'https:' ? '; Secure' : '';
116
+ if (path === '/_studio/signin') {
117
+ const key = url.searchParams.get('k') ?? '';
118
+ if (request.method === 'GET') {
119
+ // A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
120
+ return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
121
+ <p class="dim">Open this studio's private stats in this browser. The link works once.</p>
122
+ <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}"><button type="submit">Open the stats</button></form>`);
123
+ }
124
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
125
+ const session = await spendSignin(env, key);
126
+ 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);
127
+ return new Response(null, { status: 303, headers: { location: '/_studio/stats', 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
128
+ }
129
+ if (path === '/_studio/signout') {
130
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
131
+ await endSession(env, request);
132
+ return new Response(null, { status: 303, headers: { location: '/_studio/stats', 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=0${secure}` } });
133
+ }
134
+ if (path === '/_studio/stats' || path === '/_studio/stats/') {
135
+ if (request.method !== 'GET') return page('Not allowed', '<h1>Not allowed</h1>', 405);
136
+ if ((await ownerAllowed(request, env, { kinds: ['session'] })) !== 'session') return locked(cat);
137
+ let stats;
138
+ try { stats = await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams) }); } catch (error) {
139
+ return page('Stats', `<h1>No stats yet</h1><p class="note">The counters are not in this studio's database yet: run <code>npm run deploy</code> (it applies D1 migration 0002_studio_stats.sql). (${esc(String(error?.message ?? error).slice(0, 120))})</p>`, 503);
140
+ }
141
+ return page(`Stats · ${cat.studio?.name ?? 'Studio'}`, statsBody(cat, stats, url));
142
+ }
143
+ return page('Not found', '<h1>Not found</h1>', 404);
144
+ }