@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.
package/worker/index.mjs CHANGED
@@ -18,6 +18,12 @@
18
18
  * /music/, /music/<slug>/ the studio's songs, scores and loops (music/manifest.json)
19
19
  * /videos/, /videos/<slug>/ its trailers, music videos and cutscenes (videos/manifest.json)
20
20
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
21
+ * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
22
+ * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
23
+ *
24
+ * It counts, and never tracks (worker/stats.mjs): a page opened, a Play press, a room opened, a round finished,
25
+ * the peak of players, a song or video started, and which site sent the visitor (a host name), as daily counters
26
+ * in the studio's own D1. No cookie on a visitor, nothing sent anywhere.
21
27
  *
22
28
  * Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
23
29
  * built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
@@ -25,16 +31,24 @@
25
31
  * after `homie-studio storage add` (R2 needs a payment method on the account),
26
32
  * MEDIA. Everything else runs on Cloudflare's free Workers plan.
27
33
  *
28
- * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 2), run
34
+ * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 3), run
29
35
  * unchanged inside the Table, so a game that plays in `homie-studio dev`
30
36
  * plays the same way here.
31
37
  */
32
38
  import { NetRoom } from './room.mjs';
33
39
  import { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
34
40
  import { qrSvg } from './qr.mjs';
41
+ import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
42
+ import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
43
+ import { ownerRoutes } from './stats-page.mjs';
44
+
45
+ export { SEAT_MAX } from './seats.mjs';
46
+ /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
47
+ export { countVisit } from './stats.mjs';
35
48
 
36
49
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
37
50
  const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
51
+
38
52
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
39
53
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
40
54
  });
@@ -172,14 +186,62 @@ async function gameAsset(env, url, game, rest) {
172
186
  return out;
173
187
  }
174
188
 
189
+ /**
190
+ * A studio that shares its "played this week" (studio.json `stats.share`) asks the directory to re-read its
191
+ * manifest once a day per Worker instance: the directory reads the number from the studio's own site, so nothing
192
+ * it is told can be forged by anyone else. Nothing else is ever sent.
193
+ */
194
+ let sharedDay = null;
195
+ function shareDaily(cat, url, ctx) {
196
+ if (!cat.studio?.stats?.share || url.protocol !== 'https:' || sharedDay === today()) return;
197
+ sharedDay = today();
198
+ const directory = String(cat.studio.directory ?? 'https://homie.rocks').replace(/\/+$/, '');
199
+ const ping = fetch(`${directory}/api/studio/played`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url.origin }) }).catch(() => {});
200
+ if (ctx?.waitUntil) ctx.waitUntil(ping);
201
+ }
202
+
203
+ /** A song's or video's player started (the page's one beacon, sent once per page): one counter. */
204
+ async function mediaBeat(request, env, ctx, url) {
205
+ const site = request.headers.get('sec-fetch-site');
206
+ const origin = request.headers.get('origin');
207
+ if (origin && origin !== 'null' ? origin !== url.origin : site !== 'same-origin') return new Response(null, { status: 403 });
208
+ if (Number(request.headers.get('content-length') ?? 0) > 256) return new Response(null, { status: 413 });
209
+ let body = null;
210
+ try { body = JSON.parse(await request.text()); } catch { return new Response(null, { status: 400 }); }
211
+ const kind = body?.k === 'song' || body?.k === 'video' ? body.k : null;
212
+ const slug = typeof body?.s === 'string' && MEDIA_SLUG.test(body.s) ? body.s : null;
213
+ if (!kind || !slug) return new Response(null, { status: 400 });
214
+ const cat = await catalogue(env, url.origin);
215
+ const known = (kind === 'song' ? cat.songs : cat.videos) ?? [];
216
+ if (!known.some((e) => e.slug === slug)) return new Response(null, { status: 404 });
217
+ if (!isQa(request)) await count(env, ctx, { metric: kind, subject: slug }); // house QA is never counted
218
+ return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
219
+ }
220
+
175
221
  export default {
176
- async fetch(request, env) {
222
+ async fetch(request, env, ctx) {
177
223
  const url = new URL(request.url);
178
224
  const path = url.pathname;
179
225
  const parts = path.split('/').filter(Boolean);
180
226
 
227
+ if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: () => catalogue(env, url.origin) });
228
+ if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
229
+ if (path === '/api/stats') {
230
+ if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
231
+ if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
232
+ return json({ ok: false, error: 'owner-only', message: 'The studio\'s numbers are for its owner. In the studio folder: `npx --no-install homie-studio stats` prints them; `npx --no-install homie-studio stats key` gives a read key for the Homie MCP tool studio_stats.' }, 401, { 'www-authenticate': 'Bearer' });
233
+ }
234
+ const cat = await catalogue(env, url.origin);
235
+ const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
236
+ try {
237
+ return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
238
+ } catch (error) {
239
+ return json({ ok: false, error: 'no-stats', message: `The counters are not in this studio's D1 yet: \`npm run deploy\` applies migration 0002_studio_stats.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
240
+ }
241
+ }
181
242
  if (path === '/.well-known/homie-studio.json') {
182
243
  const cat = await catalogue(env, url.origin);
244
+ const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
183
245
  return json({
184
246
  v: 1,
185
247
  kind: 'homie-studio',
@@ -194,6 +256,8 @@ export default {
194
256
  })),
195
257
  songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
196
258
  videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
259
+ // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
260
+ ...(played ? { played } : {}),
197
261
  }, 200, { 'access-control-allow-origin': '*' });
198
262
  }
199
263
  if (path === '/api/games') {
@@ -210,13 +274,14 @@ export default {
210
274
  const kind = parts[0];
211
275
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
212
276
  const cat = await catalogue(env, url.origin);
213
- if (parts.length === 1) return mediaIndexPage(cat, kind);
277
+ if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind); }
214
278
  const slug = parts[1];
215
279
  const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
216
280
  const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
217
281
  if (parts.length === 2) {
218
282
  if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
219
283
  if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
284
+ await countVisit(request, env, ctx, `${kind}/${slug}`);
220
285
  return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
221
286
  }
222
287
  if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
@@ -226,6 +291,8 @@ export default {
226
291
  }
227
292
  if (path === '/' || path === '/index.html') {
228
293
  const cat = await catalogue(env, url.origin);
294
+ await countVisit(request, env, ctx, 'home');
295
+ shareDaily(cat, url, ctx);
229
296
  return homePage(cat, await liveCounts(env, cat.games ?? []));
230
297
  }
231
298
 
@@ -236,13 +303,14 @@ export default {
236
303
  if (!meta) return env.ASSETS.fetch(request);
237
304
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
238
305
  const sub = parts.slice(1).join('/');
239
- if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
306
+ if (sub === '') { await countVisit(request, env, ctx, game); shareDaily(cat, url, ctx); return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0); }
240
307
  if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
308
+ await countVisit(request, env, ctx, game, 'screen');
241
309
  // The big screen picks its room now, so the QR it shows puts every phone in that same room.
242
310
  let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
243
311
  if (!room) {
244
312
  try {
245
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
313
+ const max = seatsOf(meta);
246
314
  room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
247
315
  } catch { room = null; }
248
316
  }
@@ -251,9 +319,9 @@ export default {
251
319
  try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
252
320
  return playPage(cat, meta, { screen: true, joinUrl, qr, room });
253
321
  }
254
- if (sub === 'play') return playPage(cat, meta);
322
+ if (sub === 'play') { await countVisit(request, env, ctx, game, 'play'); shareDaily(cat, url, ctx); return playPage(cat, meta); }
255
323
  if (sub === 'api/lobby') {
256
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
324
+ const max = seatsOf(meta);
257
325
  return env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
258
326
  }
259
327
  if (sub === '__net' || sub === '__watch') {
@@ -261,7 +329,7 @@ export default {
261
329
  const room = url.searchParams.get('room') || 'main';
262
330
  if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
263
331
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
264
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
332
+ const max = seatsOf(meta);
265
333
  const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
266
334
  return stub.fetch(new Request(target, request));
267
335
  }
@@ -294,6 +362,8 @@ export class Table {
294
362
  this.code = null;
295
363
  this.recorded = 0;
296
364
  this.lastReport = -1;
365
+ /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
366
+ this.openCounted = false;
297
367
  ctx.blockConcurrencyWhile(async () => {
298
368
  this.saved = (await ctx.storage.get('net')) ?? null;
299
369
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -308,6 +378,7 @@ export class Table {
308
378
  this.room = new NetRoom({
309
379
  code,
310
380
  maxPlayers: max,
381
+ perIp: perAddress(max),
311
382
  store: {
312
383
  save: (o) => { storage.put('net', o).catch(() => {}); },
313
384
  clear: () => { storage.delete('net').catch(() => {}); },
@@ -321,12 +392,14 @@ export class Table {
321
392
  const url = new URL(request.url);
322
393
  const game = url.searchParams.get('game');
323
394
  const code = url.searchParams.get('room');
324
- const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
395
+ const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
325
396
  const room = this.roomFor(game, code, max);
326
397
  const [client, server] = Object.values(new WebSocketPair());
327
398
  server.accept();
328
399
  const conn = {
329
400
  ip: request.headers.get('cf-connecting-ip'),
401
+ // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
402
+ qa: isQa(request),
330
403
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
331
404
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
332
405
  buffered: () => 0,
@@ -337,7 +410,17 @@ export class Table {
337
410
  server.addEventListener('error', () => w.onClose());
338
411
  } else {
339
412
  const h = room.attach(conn);
340
- server.addEventListener('message', (e) => { h.onMessage(typeof e.data === 'string' ? e.data : ''); });
413
+ // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
414
+ // seats, so it is not counted twice). One counter write per room.
415
+ const fresh = room.seats.size === 0;
416
+ server.addEventListener('message', (e) => {
417
+ h.onMessage(typeof e.data === 'string' ? e.data : '');
418
+ if (fresh && !this.openCounted && room.seats.size > 0) {
419
+ this.openCounted = true;
420
+ if (conn.qa) return;
421
+ this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
422
+ }
423
+ });
341
424
  server.addEventListener('close', () => { h.onClose(); this.report(); });
342
425
  server.addEventListener('error', () => { h.onClose(); this.report(); });
343
426
  }
@@ -354,6 +437,7 @@ export class Table {
354
437
  room.tick();
355
438
  n += 1;
356
439
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
440
+ if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
357
441
  if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); }
358
442
  }, 250);
359
443
  }
@@ -363,10 +447,16 @@ export class Table {
363
447
  const room = this.room;
364
448
  if (!room || !this.game) return;
365
449
  const players = room.facts().counts.players;
366
- if (players === this.lastReport) return;
450
+ // People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
451
+ let people = 0;
452
+ for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.conn?.qa) people += 1;
453
+ // On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
454
+ if (players === this.lastReport && people === this.lastPeople && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
367
455
  this.lastReport = players;
456
+ this.lastPeople = people;
457
+ this.lastReportAt = Date.now();
368
458
  const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
369
- this.ctx.waitUntil(lobby.fetch('https://lobby/report', { method: 'POST', body: JSON.stringify({ room: this.code, players }) }).catch(() => {}));
459
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people }) }).catch(() => {}));
370
460
  }
371
461
 
372
462
  /** A round the host called over becomes one D1 row (once per round number). */
@@ -375,11 +465,16 @@ export class Table {
375
465
  if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
376
466
  this.recorded = r.n;
377
467
  this.ctx.storage.put('recorded', r.n).catch(() => {});
378
- const results = Array.isArray(r.results) ? r.results.slice(0, 16) : [];
468
+ const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
379
469
  const humans = results.filter((row) => row && !row.bot).length;
380
- this.ctx.waitUntil(this.env.DB.prepare(
381
- 'INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)',
382
- ).bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString()).run().catch(() => {}));
470
+ // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
471
+ const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null).every((c) => c.conn?.qa);
472
+ const db = this.env.DB;
473
+ const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
474
+ .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
475
+ // The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
476
+ const counters = qaOnly ? [] : [counter(this.env, { metric: 'round', subject: this.game ?? '', source: humans ? 'people' : 'bots' }), humans ? counter(this.env, { metric: 'humans', subject: this.game ?? '', n: humans }) : null].filter(Boolean);
477
+ this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(() => {})));
383
478
  }
384
479
  }
385
480
 
@@ -390,16 +485,40 @@ export class Table {
390
485
  * in the same room.
391
486
  */
392
487
  export class Lobby {
393
- constructor(ctx) {
488
+ constructor(ctx, env) {
394
489
  this.ctx = ctx;
490
+ this.env = env;
395
491
  this.rooms = new Map();
396
492
  this.next = 1;
493
+ /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
494
+ this.live = new Map();
495
+ this.peak = { day: null, players: 0, room: 0 };
397
496
  ctx.blockConcurrencyWhile(async () => {
398
497
  const saved = await ctx.storage.get('lobby');
399
498
  if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
400
499
  });
401
500
  }
402
501
 
502
+ /**
503
+ * The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
504
+ * reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
505
+ */
506
+ notePeak(room, players, game) {
507
+ const now = Date.now();
508
+ if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
509
+ for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
510
+ const day = today();
511
+ if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
512
+ let all = 0;
513
+ for (const r of this.live.values()) all += r.players;
514
+ const writes = [];
515
+ if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
516
+ if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
517
+ if (game) this.game = game;
518
+ const list = writes.filter(Boolean);
519
+ if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
520
+ }
521
+
403
522
  save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
404
523
 
405
524
  prune(now) {
@@ -414,7 +533,7 @@ export class Lobby {
414
533
  const now = Date.now();
415
534
  this.prune(now);
416
535
  if (url.pathname === '/join') {
417
- const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
536
+ const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
418
537
  let best = null;
419
538
  for (const r of this.rooms.values()) {
420
539
  const fill = r.players + r.pending.length;
@@ -434,18 +553,25 @@ export class Lobby {
434
553
  if (url.pathname === '/report' && request.method === 'POST') {
435
554
  const body = await request.json().catch(() => ({}));
436
555
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
556
+ this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
437
557
  // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
438
558
  if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
439
559
  const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
440
560
  const grew = Math.max(0, (Number(body.players) || 0) - r.players);
561
+ const changed = !this.rooms.has(r.name) || r.players !== Math.max(0, Number(body.players) || 0);
441
562
  r.pending.splice(0, grew);
442
563
  r.players = Math.max(0, Number(body.players) || 0);
443
564
  r.at = now;
444
565
  this.rooms.set(r.name, r);
445
- this.save();
566
+ if (changed) this.save(); // the minute's "still here" report writes nothing
446
567
  return json({ ok: true });
447
568
  }
448
569
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
570
+ if (url.pathname === '/now') {
571
+ let players = 0;
572
+ for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
573
+ return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
574
+ }
449
575
  return json({ error: 'not found' }, 404);
450
576
  }
451
577
  }
package/worker/pages.mjs CHANGED
@@ -126,6 +126,12 @@ function rightsLine(e) {
126
126
  return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
127
127
  }
128
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
+
129
135
  const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta property="${esc(k)}" content="${esc(v)}">`).join('');
130
136
 
131
137
  /** /music/<slug>/: the player, the facts, the words, the loops and stems another game may use. */
@@ -148,7 +154,7 @@ export function songPage(cat, e, origin = '') {
148
154
  ${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
149
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>` : ''}
150
156
  ${rightsLine(e)}
151
- </div>`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
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) }) });
152
158
  }
153
159
 
154
160
  /** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
@@ -173,7 +179,7 @@ export function videoPage(cat, e, origin = '') {
173
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>` : '')}
174
180
  </section>
175
181
  ${rightsLine(e)}
176
- </div>${pick}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
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) }) });
177
183
  }
178
184
 
179
185
  export function gamePage(cat, g, playing = 0) {
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
+ }