@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/README.md +38 -2
- package/bin/homie-studio.mjs +49 -9
- package/lib/build.mjs +44 -7
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +47 -3
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +42 -0
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +1 -1
- package/worker/index.mjs +145 -19
- package/worker/pages.mjs +8 -2
- package/worker/room.mjs +9 -2
- package/worker/seats.mjs +20 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +311 -0
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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(
|
|
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
|
|
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 : (
|
|
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);
|
package/worker/seats.mjs
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
}
|