@homie-rocks/studio 0.5.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.
@@ -0,0 +1,311 @@
1
+ /**
2
+ * STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
3
+ *
4
+ * Every studio counts, in its own Worker and its own D1, on Cloudflare's free plan. Nothing goes to a third
5
+ * party, no cookie is set on a visitor, and no visitor is identified: a count is a number on a day, never a row
6
+ * about a person. Referrers are kept as a host name only (never a path, a query or an address).
7
+ *
8
+ * WHAT IS COUNTED, one D1 row write each (an UPSERT on a daily counter; never per frame):
9
+ * visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
10
+ * play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
11
+ * screen a big screen opened (/<game>/tv)
12
+ * room a room opened: the first seat taken in an empty room (public, or a named one)
13
+ * round a round finished (source: `people` when a person was in it, `bots` when only bots were)
14
+ * humans the people in finished rounds, summed
15
+ * peak the most people playing one game at once that day (all its rooms), and peak-room in one room
16
+ * song a song's player started; video: a video's player started
17
+ * Each visit, play and screen row carries WHERE FROM: '' (typed, bookmarked, or this site's own link), the
18
+ * referring site's host (homie.rocks, another studio, anywhere on the web), or `via:<tag>` from a ?via= link.
19
+ * Concurrent players now come from the Lobby at read time; nothing stores them.
20
+ *
21
+ * WHAT IS NOT COUNTED: prefetches and prerenders, crawlers and link previews, anything that is not a browser
22
+ * opening a page, and the house's own QA (a user agent tagged homie-house-qa or homie-studio-check).
23
+ *
24
+ * WHO READS THEM: only the studio's owner, whose proof is the studio's own Cloudflare login (Wrangler).
25
+ * `homie-studio stats` prints them (a ten-minute read key, minted and dropped by the CLI)
26
+ * `homie-studio stats key` a read key for the Homie MCP tool `studio_stats` (1 hour by default)
27
+ * `homie-studio stats link` a one-time sign-in link: /_studio/stats in the owner's own browser
28
+ * 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, only under /_studio/). There is no other way in.
30
+ *
31
+ * FREE PLAN: D1 allows 100,000 row writes and 5 million row reads a day. A visit, a play, a room and a
32
+ * song or video start are one write each; a finished round is three (its row in `rounds`, the round and the
33
+ * humans counters). Past the day's writes, counting stops until 00:00 UTC and the site keeps working.
34
+ */
35
+
36
+ export const STATS_MIGRATION_FILE = '0002_studio_stats.sql';
37
+ export const STATS_MIGRATION = `-- Studio metrics (@homie-rocks/studio 0.6.0): daily counters. Count, don't track: no row is about a person.
38
+ CREATE TABLE IF NOT EXISTS stats_daily (
39
+ day TEXT NOT NULL,
40
+ metric TEXT NOT NULL,
41
+ subject TEXT NOT NULL,
42
+ source TEXT NOT NULL,
43
+ n INTEGER NOT NULL,
44
+ PRIMARY KEY (day, metric, subject, source)
45
+ ) WITHOUT ROWID;
46
+ -- The owner's read keys and page sessions: only a SHA-256 of each, never the key.
47
+ CREATE TABLE IF NOT EXISTS stats_keys (
48
+ hash TEXT PRIMARY KEY,
49
+ kind TEXT NOT NULL,
50
+ expires_at INTEGER NOT NULL
51
+ ) WITHOUT ROWID;
52
+ `;
53
+
54
+ export const METRICS = Object.freeze(['visit', 'play', 'screen', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
55
+ const MAX_METRICS = new Set(['peak', 'peak-room']);
56
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
57
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
58
+ const KEY = /^hsk_[a-f0-9]{48}$/;
59
+ const SESSION = /^[a-f0-9]{64}$/;
60
+ export const OWNER_COOKIE = 'studio_owner';
61
+ const HOUR = 3600_000;
62
+
63
+ export const today = (at = Date.now()) => new Date(at).toISOString().slice(0, 10);
64
+ const dayOffset = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400_000).toISOString().slice(0, 10);
65
+
66
+ async function sha256(text) {
67
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
68
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
69
+ }
70
+ function randomHex(bytes) {
71
+ const raw = new Uint8Array(bytes);
72
+ crypto.getRandomValues(raw);
73
+ return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ counting */
77
+
78
+ /** A statement that adds `n` to one counter (or keeps the larger, for peaks), for a D1 batch. */
79
+ export function counter(env, { metric, subject = '', source = '', n = 1, day = today() }) {
80
+ if (!env?.DB || !METRICS.includes(metric)) return null;
81
+ const sql = MAX_METRICS.has(metric)
82
+ ? 'INSERT INTO stats_daily (day, metric, subject, source, n) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(day, metric, subject, source) DO UPDATE SET n = max(n, excluded.n)'
83
+ : 'INSERT INTO stats_daily (day, metric, subject, source, n) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(day, metric, subject, source) DO UPDATE SET n = n + excluded.n';
84
+ return env.DB.prepare(sql).bind(day, metric, String(subject).slice(0, 80), String(source).slice(0, 80), Math.max(0, Math.floor(Number(n) || 0)));
85
+ }
86
+
87
+ /** Run a counter write after the response (ctx.waitUntil), or before it when the caller gave no ctx. Never throws. */
88
+ export async function count(env, ctx, row) {
89
+ const stmt = counter(env, row);
90
+ if (!stmt) return;
91
+ const p = stmt.run().catch(() => {}); // before migration 0002, or past the free plan's daily writes: not counted
92
+ if (ctx?.waitUntil) ctx.waitUntil(p); else await p;
93
+ }
94
+
95
+ // Crawlers, link previews, fetch libraries and headless browsers: not a person opening a page.
96
+ const NOT_A_PERSON = /bot\b|bot\/|crawl|spider|slurp|facebookexternalhit|embedly|preview|whatsapp|telegram|discord|slack|skype|curl|wget|python|go-http|java\/|okhttp|node-fetch|undici|axios|headless|lighthouse|pagespeed|homie-house-qa|homie-studio-check|homie-directory/i;
97
+
98
+ const QA = /homie-house-qa|homie-studio-check/i;
99
+ /** House QA and `homie-studio check` tag their user agents: their visits, rooms, rounds and peaks are not counted. */
100
+ export const isQa = (request) => QA.test(request.headers.get('user-agent') ?? '');
101
+
102
+ /**
103
+ * Whether this request is a person's browser opening a page: a GET navigation (Sec-Fetch-Dest: document, or an
104
+ * HTML Accept from a browser too old to say), not a prefetch or prerender, not a crawler, not house QA.
105
+ */
106
+ export function isVisit(request) {
107
+ if (request.method !== 'GET') return false;
108
+ const h = request.headers;
109
+ const dest = h.get('sec-fetch-dest');
110
+ if (dest ? dest !== 'document' : !/text\/html/.test(h.get('accept') ?? '')) return false;
111
+ if (/prefetch|prerender/i.test(`${h.get('sec-purpose') ?? ''} ${h.get('purpose') ?? ''} ${h.get('x-purpose') ?? ''} ${h.get('x-moz') ?? ''}`)) return false;
112
+ const ua = h.get('user-agent') ?? '';
113
+ if (!ua || NOT_A_PERSON.test(ua)) return false;
114
+ return true;
115
+ }
116
+
117
+ /** Where a visit came from: '' for this site or no referrer, the referring host, or `via:<tag>`. A host, never a path. */
118
+ export function sourceOf(request, url) {
119
+ let host = '';
120
+ try { host = new URL(request.headers.get('referer') ?? '').host.toLowerCase(); } catch { host = ''; }
121
+ if (host.startsWith('www.')) host = host.slice(4);
122
+ const self = url.host.toLowerCase().replace(/^www\./, '');
123
+ if (host && host !== self && /^[a-z0-9.-]+(?::\d{1,5})?$/.test(host)) return host.slice(0, 80);
124
+ const via = url.searchParams.get('via') ?? '';
125
+ return /^[a-z0-9][a-z0-9.-]{0,39}$/i.test(via) ? `via:${via.toLowerCase()}` : '';
126
+ }
127
+
128
+ /** Count one page view: `metric` visit, play or screen, of `subject`. For a studio's own wrapper pages too. */
129
+ export async function countVisit(request, env, ctx, subject, metric = 'visit') {
130
+ if (!isVisit(request)) return;
131
+ const url = new URL(request.url);
132
+ await count(env, ctx, { metric, subject, source: sourceOf(request, url) });
133
+ }
134
+
135
+ /* ------------------------------------------------------------------ the owner's keys */
136
+
137
+ /** Whether a presented key (Bearer header) or the owner's page session is valid. */
138
+ export async function ownerAllowed(request, env, { kinds = ['read', 'session'] } = {}) {
139
+ if (!env?.DB) return false;
140
+ const auth = request.headers.get('authorization') ?? '';
141
+ const bearer = /^Bearer\s+(\S+)$/i.exec(auth)?.[1] ?? null;
142
+ const cookie = cookieValue(request, OWNER_COOKIE);
143
+ const tries = [];
144
+ if (bearer && KEY.test(bearer) && kinds.includes('read')) tries.push(['read', bearer]);
145
+ if (cookie && SESSION.test(cookie) && kinds.includes('session')) tries.push(['session', cookie]);
146
+ for (const [kind, value] of tries) {
147
+ try {
148
+ 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 === kind && Number(row.expires_at) > Date.now()) return kind;
150
+ } catch { return false; }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ export function cookieValue(request, name) {
156
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
157
+ const [k, ...v] = part.trim().split('=');
158
+ if (k === name) return v.join('=');
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /** A one-time sign-in key turns into a 30-day page session (the key is spent). Returns the session value or null. */
164
+ export async function spendSignin(env, key) {
165
+ if (!env?.DB || !KEY.test(String(key ?? ''))) return null;
166
+ const hash = await sha256(key);
167
+ try {
168
+ const row = await env.DB.prepare('SELECT kind, expires_at FROM stats_keys WHERE hash = ?1').bind(hash).first();
169
+ if (!row || row.kind !== 'signin' || Number(row.expires_at) <= Date.now()) return null;
170
+ const session = randomHex(32);
171
+ await env.DB.batch([
172
+ env.DB.prepare('DELETE FROM stats_keys WHERE hash = ?1').bind(hash),
173
+ env.DB.prepare("INSERT INTO stats_keys (hash, kind, expires_at) VALUES (?1, 'session', ?2)").bind(await sha256(session), Date.now() + 30 * 24 * HOUR),
174
+ env.DB.prepare('DELETE FROM stats_keys WHERE expires_at < ?1').bind(Date.now()),
175
+ ]);
176
+ return session;
177
+ } catch { return null; }
178
+ }
179
+
180
+ export async function endSession(env, request) {
181
+ const cookie = cookieValue(request, OWNER_COOKIE);
182
+ if (!env?.DB || !cookie || !SESSION.test(cookie)) return;
183
+ try { await env.DB.prepare("DELETE FROM stats_keys WHERE hash = ?1 AND kind = 'session'").bind(await sha256(cookie)).run(); } catch { /* gone */ }
184
+ }
185
+
186
+ /* ------------------------------------------------------------------ reading */
187
+
188
+ /** The day range a request asks for: ?range=1d|7d|30d|90d (default 7d), or ?from=&to= (UTC days, up to 366). */
189
+ export function rangeOf(params, now = Date.now()) {
190
+ const end = today(now);
191
+ const from = params.get('from');
192
+ const to = params.get('to');
193
+ if (from && DAY.test(from)) {
194
+ const last = to && DAY.test(to) && to <= end ? to : end;
195
+ const first = from <= last ? from : last;
196
+ const span = Math.round((Date.parse(last) - Date.parse(first)) / 86400_000) + 1;
197
+ return span > 366 ? { from: dayOffset(last, -365), to: last, days: 366 } : { from: first, to: last, days: span };
198
+ }
199
+ const days = { '1d': 1, today: 1, '7d': 7, week: 7, '30d': 30, month: 30, '90d': 90 }[params.get('range') ?? '7d'] ?? 7;
200
+ return { from: dayOffset(end, -(days - 1)), to: end, days };
201
+ }
202
+
203
+ /** The Lobby's count of people in every room of each game, right now. */
204
+ async function playingNow(env, games) {
205
+ const out = {};
206
+ await Promise.all(games.map(async (g) => {
207
+ try {
208
+ const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/now');
209
+ const body = await res.json();
210
+ out[g.id] = { players: Number(body.players) || 0, rooms: Number(body.rooms) || 0 };
211
+ } catch { out[g.id] = { players: 0, rooms: 0 }; }
212
+ }));
213
+ return out;
214
+ }
215
+
216
+ const HUB = /^(?:www\.)?homie\.rocks$/;
217
+ /**
218
+ * What a referring host is: `hub` (homie.rocks, the directory), `studio` (a Homie studio: any *.homie.rocks studio
219
+ * address, or a site the caller knows is one), `search`, or `web`. homie.rocks's `studio_stats` passes the
220
+ * directory's sites; the studio's own page passes none.
221
+ */
222
+ export function kindOf(host, studios = new Set()) {
223
+ if (host.startsWith('via:')) return 'link';
224
+ if (HUB.test(host)) return 'hub';
225
+ if (studios.has(host) || /\.homie\.rocks$/.test(host)) return 'studio';
226
+ if (/(^|\.)(google|bing|duckduckgo|yahoo|baidu|yandex|ecosia|brave|kagi)\.[a-z.]+$/.test(host) || /^search\./.test(host)) return 'search';
227
+ return 'web';
228
+ }
229
+
230
+ /**
231
+ * The numbers for a day range: totals, per game, per song and video, referrers (each with its kind), and one
232
+ * row per day. `only` narrows to one game, song or video. Reads one D1 query (rows in range) and each Lobby.
233
+ */
234
+ export async function readStats(env, cat, { range, only = null, studios = new Set() } = {}) {
235
+ const rows = (await env.DB.prepare('SELECT day, metric, subject, source, n FROM stats_daily WHERE day >= ?1 AND day <= ?2 LIMIT 50000')
236
+ .bind(range.from, range.to).all()).results ?? [];
237
+ const games = (cat.games ?? []).filter((g) => !only || (only.kind === 'game' && only.id === g.id));
238
+ const songs = (cat.songs ?? []).filter((e) => !only || (only.kind === 'song' && only.id === e.slug));
239
+ const videos = (cat.videos ?? []).filter((e) => !only || (only.kind === 'video' && only.id === e.slug));
240
+ const page = only && only.kind !== 'game' ? `${only.kind === 'song' ? 'music' : 'videos'}/${only.id}` : null;
241
+ const inScope = (r) => !only
242
+ || (only.kind === 'game' ? r.subject === only.id && r.metric !== 'song' && r.metric !== 'video' : r.subject === page || (r.metric === only.kind && r.subject === only.id));
243
+ const scoped = rows.filter(inScope);
244
+ const sum = (pred) => scoped.reduce((n, r) => n + (pred(r) ? Number(r.n) : 0), 0);
245
+ const maxOf = (pred) => scoped.reduce((n, r) => Math.max(n, pred(r) ? Number(r.n) : 0), 0);
246
+ const now = await playingNow(env, games);
247
+ const perGame = games.map((g) => {
248
+ const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
249
+ return {
250
+ id: g.id, name: g.name, seats: g.players?.max ?? null,
251
+ visits: of('visit'), plays: of('play'), screens: of('screen'), rooms: of('room'),
252
+ rounds: of('round'), roundsWithPeople: of('round', (r) => r.source === 'people'), peopleInRounds: of('humans'),
253
+ peakPlayers: maxOf((r) => r.metric === 'peak' && r.subject === g.id), peakInOneRoom: maxOf((r) => r.metric === 'peak-room' && r.subject === g.id),
254
+ playingNow: now[g.id]?.players ?? 0, roomsNow: now[g.id]?.rooms ?? 0,
255
+ };
256
+ });
257
+ const media = (list, kind, dir) => list.map((e) => ({
258
+ slug: e.slug, title: e.title,
259
+ visits: sum((r) => r.metric === 'visit' && r.subject === `${dir}/${e.slug}`),
260
+ [kind === 'song' ? 'plays' : 'views']: sum((r) => r.metric === kind && r.subject === e.slug),
261
+ }));
262
+ const refs = new Map();
263
+ for (const r of scoped) {
264
+ if (!r.source || !['visit', 'play', 'screen'].includes(r.metric)) continue;
265
+ const x = refs.get(r.source) ?? { from: r.source, kind: kindOf(r.source, studios), visits: 0, plays: 0 };
266
+ if (r.metric === 'play') x.plays += Number(r.n); else x.visits += Number(r.n);
267
+ refs.set(r.source, x);
268
+ }
269
+ const referrers = [...refs.values()].sort((a, b) => (b.visits + b.plays) - (a.visits + a.plays)).slice(0, 100);
270
+ const byKind = (k) => referrers.filter((x) => x.kind === k).reduce((n, x) => n + x.visits + x.plays, 0);
271
+ const days = [];
272
+ for (let d = range.from; d <= range.to; d = dayOffset(d, 1)) {
273
+ const of = (m) => scoped.reduce((n, r) => n + (r.day === d && r.metric === m ? Number(r.n) : 0), 0);
274
+ days.push({ day: d, visits: of('visit'), plays: of('play'), rounds: of('round'), people: of('humans'), songs: of('song'), videos: of('video') });
275
+ }
276
+ return {
277
+ v: 1, kind: 'homie-studio-stats', studio: cat.studio?.name ?? null, range, ...(only ? { only } : {}),
278
+ totals: {
279
+ visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'),
280
+ rooms: sum((r) => r.metric === 'room'), rounds: sum((r) => r.metric === 'round'), roundsWithPeople: sum((r) => r.metric === 'round' && r.source === 'people'),
281
+ peopleInRounds: sum((r) => r.metric === 'humans'), songPlays: sum((r) => r.metric === 'song'), videoViews: sum((r) => r.metric === 'video'),
282
+ peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
283
+ playingNow: perGame.reduce((n, g) => n + g.playingNow, 0),
284
+ },
285
+ crossings: { fromHub: byKind('hub'), fromStudios: byKind('studio'), fromSearch: byKind('search'), fromWeb: byKind('web'), fromLinks: byKind('link') },
286
+ // The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
287
+ pages: { home: sum((r) => r.metric === 'visit' && r.subject === 'home'), music: sum((r) => r.metric === 'visit' && r.subject === 'music'), videos: sum((r) => r.metric === 'visit' && r.subject === 'videos') },
288
+ games: perGame, songs: media(songs, 'song', 'music'), videos: media(videos, 'video', 'videos'), referrers, days,
289
+ note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.',
290
+ };
291
+ }
292
+
293
+ /** What a studio that shares may tell the directory: Play presses and rounds with people in the last 7 days. */
294
+ export async function playedThisWeek(env) {
295
+ if (!env?.DB) return null;
296
+ const to = today();
297
+ try {
298
+ const row = await env.DB.prepare("SELECT SUM(CASE WHEN metric = 'play' THEN n ELSE 0 END) AS plays, SUM(CASE WHEN metric = 'round' AND source = 'people' THEN n ELSE 0 END) AS rounds FROM stats_daily WHERE day >= ?1 AND day <= ?2 AND metric IN ('play', 'round')")
299
+ .bind(dayOffset(to, -6), to).first();
300
+ return { days: 7, plays: Number(row?.plays) || 0, rounds: Number(row?.rounds) || 0, to };
301
+ } catch { return null; }
302
+ }
303
+
304
+ /** Parse ?game= / ?song= / ?video= into one narrowing, or null. */
305
+ export function onlyOf(params) {
306
+ for (const kind of ['game', 'song', 'video']) {
307
+ const id = params.get(kind);
308
+ if (id && SLUG.test(id)) return { kind, id };
309
+ }
310
+ return null;
311
+ }