@homie-rocks/studio 0.6.0 → 0.7.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
@@ -1,25 +1,34 @@
1
1
  /**
2
2
  * @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
3
3
  *
4
- * One Worker serves the studio's pages and its public rooms:
4
+ * One Worker serves the studio's pages and its public rooms (site/SITE.md: the sections, the landings, posts and
5
+ * what a studio's site/ folder overrides):
5
6
  *
6
- * / the studio's home: every game, with a Play button
7
- * /<game>/ the game's page
8
- * /<game>/play the play shell: asks the Lobby for a public room and
9
- * boots the game in a sandboxed frame, seated at once
10
- * /<game>/tv the big screen: the same room as a spectator, with a
11
- * QR code phones scan to join (also /<game>/play?screen=1)
7
+ * / Home: the featured game, live rooms, games, latest posts, videos, music
8
+ * /games/ /rooms/ /posts/ the sections, as on homie.rocks (a section with nothing in it answers 404)
9
+ * /music/ /videos/ songs and videos (music/manifest.json, videos/manifest.json)
10
+ * /posts/<slug>/ a post (posts/*.md); /posts/feed.xml (Atom) and /posts/feed.json (JSON Feed)
11
+ * /<game>/ the game's landing: hero, Play, phone / computer / TV, live rooms, how to play, credits
12
+ * /<game>/credits the licence texts the game ships with
13
+ * /<game>/live the landing's live line and rooms (JSON)
14
+ * /<game>/play the play shell: asks the Lobby for a public room and boots the game in a
15
+ * sandboxed frame, seated at once; the room goes into the address, and a small
16
+ * room button shares it (Invite, Big screen, the room code)
17
+ * /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
18
+ * (also /<game>/play?screen=1)
12
19
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
13
20
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
14
21
  * /<game>/__watch?room= the room's facts, for the shell
15
22
  * /<game>/api/lobby which public room to join (Lobby Durable Object)
16
- * /api/games the catalogue plus live counts (D1 + Lobby)
23
+ * /api/games, /api/rooms the catalogue plus live counts; every public room playing now
17
24
  * /.well-known/homie-studio.json what the homie.rocks directory reads
18
- * /music/, /music/<slug>/ the studio's songs, scores and loops (music/manifest.json)
19
- * /videos/, /videos/<slug>/ its trailers, music videos and cutscenes (videos/manifest.json)
20
25
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
21
26
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
22
27
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
28
+ * /_homie/site.js the pages' one script
29
+ *
30
+ * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
31
+ * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
23
32
  *
24
33
  * It counts, and never tracks (worker/stats.mjs): a page opened, a Play press, a room opened, a round finished,
25
34
  * the peak of players, a song or video started, and which site sent the visitor (a host name), as daily counters
@@ -36,18 +45,26 @@
36
45
  * plays the same way here.
37
46
  */
38
47
  import { NetRoom } from './room.mjs';
39
- import { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
48
+ import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
40
49
  import { qrSvg } from './qr.mjs';
41
50
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
51
+ import {
52
+ SITE_JS, atomFeed, creditsPage, customPage, gameLanding, gamesPage, homePage, jsonFeed, mediaIndexPage, notFoundPage,
53
+ postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
54
+ } from './site.mjs';
42
55
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
43
56
  import { ownerRoutes } from './stats-page.mjs';
57
+ import { STUDIO_VERSION_TAG } from './version.mjs';
44
58
 
45
59
  export { SEAT_MAX } from './seats.mjs';
46
60
  /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
47
61
  export { countVisit } from './stats.mjs';
48
62
 
49
63
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
50
- const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
64
+ const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
65
+ /** A game's own files, framed by its play page: never x-frame-options DENY. */
66
+ const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
67
+ const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
51
68
 
52
69
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
53
70
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
@@ -62,23 +79,37 @@ async function catalogue(env, origin) {
62
79
  } catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
63
80
  }
64
81
 
82
+ /** The posts with their HTML (site/dist/_site/posts.json), for a post's page and the feeds. */
83
+ async function postsOf(env, origin) {
84
+ try {
85
+ const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
86
+ return res.ok ? ((await res.json()).posts ?? []) : [];
87
+ } catch { return []; }
88
+ }
89
+
65
90
  async function claimOf(env) {
66
91
  if (!env.DB) return null;
67
92
  try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?').bind('homie_claim').first())?.value ?? null; } catch { return null; }
68
93
  }
69
94
 
70
- async function liveCounts(env, games) {
71
- const out = {};
95
+ /** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
96
+ async function roomsOf(env, games) {
97
+ const rooms = [];
98
+ const live = {};
72
99
  await Promise.all(games.map(async (g) => {
73
- try {
74
- const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms');
75
- const rooms = (await res.json()).rooms ?? [];
76
- out[g.id] = rooms.reduce((n, r) => n + (r.players || 0), 0);
77
- } catch { out[g.id] = 0; }
100
+ let list = [];
101
+ try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
102
+ const busy = list.filter((r) => (r.players || 0) > 0);
103
+ live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
104
+ for (const r of busy) rooms.push(roomView(g, r, seatsOf(g)));
78
105
  }));
79
- return out;
106
+ rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
107
+ return { rooms, live };
80
108
  }
81
109
 
110
+ /** Kept for a studio's own wrapper Worker that reads `/api/games`'s `live`. */
111
+ async function liveCounts(env, games) { return (await roomsOf(env, games)).live; }
112
+
82
113
  const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
83
114
 
84
115
  /** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
@@ -92,7 +123,7 @@ function parseRange(header, size) {
92
123
  return { start, end };
93
124
  }
94
125
 
95
- /** A media file from the site's own assets, answering a byte range (phones seek audio and video with them). */
126
+ /** A media file from the site's own assets, answering a byte range (phones seek audio and video with them; Safari needs them to play a video at all). */
96
127
  async function assetWithRange(request, env, url) {
97
128
  const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
98
129
  const range = request.headers.get('range');
@@ -145,10 +176,13 @@ function mediaRow(e, origin, kind) {
145
176
  }
146
177
 
147
178
  /** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
148
- async function gameDocument(request, env, url, game, meta) {
179
+ async function gameDocument(request, env, url, game, meta, cat) {
180
+ const asked = url.searchParams.get('room');
181
+ // A room code the relay cannot use is refused here too, never swapped for another room.
182
+ if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
149
183
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
150
- if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`);
151
- const room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : 'main';
184
+ if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
185
+ const room = asked ?? 'main';
152
186
  const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
153
187
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
154
188
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
@@ -169,17 +203,18 @@ async function gameDocument(request, env, url, game, meta) {
169
203
  return new Response(html, {
170
204
  headers: {
171
205
  'content-type': 'text/html; charset=utf-8',
172
- 'cache-control': 'no-store',
206
+ 'cache-control': 'no-store, no-transform',
173
207
  'access-control-allow-origin': '*',
174
208
  // The game runs in an opaque origin: it cannot read this site's storage or cookies.
175
- 'content-security-policy': "sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors 'self'",
209
+ 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${frameAncestors(cat)}`,
176
210
  },
177
211
  });
178
212
  }
179
213
 
180
- async function gameAsset(env, url, game, rest) {
214
+ async function gameAsset(request, env, url, game, rest) {
181
215
  if (rest.includes('..')) return new Response('not found', { status: 404 });
182
- const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/${rest}`));
216
+ const target = new URL(`${url.origin}/games/${game}/${rest}`);
217
+ const res = MEDIA_FILE.test(rest) && request.headers.get('range') ? await assetWithRange(request, env, target) : await env.ASSETS.fetch(new Request(target));
183
218
  const out = new Response(res.body, res);
184
219
  // Module scripts from the opaque-origin frame are CORS requests.
185
220
  out.headers.set('access-control-allow-origin', '*');
@@ -218,133 +253,252 @@ async function mediaBeat(request, env, ctx, url) {
218
253
  return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
219
254
  }
220
255
 
256
+ /**
257
+ * EVERY HTML ANSWER IS no-transform, AND NEVER FRAMED BY ANOTHER SITE. A custom domain on a zone with Cloudflare Web
258
+ * Analytics' automatic setup gets a beacon injected into every HTML answer not marked no-transform, and the beacon
259
+ * posts to /cdn-cgi/rum, which a Worker custom domain does not serve. Pages that name no frame rule get
260
+ * x-frame-options DENY (the game's own files are framed by its play page, so they keep theirs).
261
+ */
262
+ function finish(res, path) {
263
+ if (!res || res.status === 101 || res.webSocket) return res;
264
+ if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
265
+ const cache = res.headers.get('cache-control') ?? '';
266
+ const addNt = !/no-transform/i.test(cache);
267
+ const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path);
268
+ if (!addNt && !addXfo) return res;
269
+ const headers = new Headers(res.headers);
270
+ if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
271
+ if (addXfo) headers.set('x-frame-options', 'DENY');
272
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
273
+ }
274
+
221
275
  export default {
222
276
  async fetch(request, env, ctx) {
223
- const url = new URL(request.url);
224
- const path = url.pathname;
225
- const parts = path.split('/').filter(Boolean);
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);
277
+ return finish(await route(request, env, ctx), new URL(request.url).pathname);
278
+ },
279
+ };
280
+
281
+ async function route(request, env, ctx) {
282
+ const url = new URL(request.url);
283
+ const path = url.pathname;
284
+ const parts = path.split('/').filter(Boolean);
285
+ const read = request.method === 'GET' || request.method === 'HEAD';
286
+ let catP = null;
287
+ const getCat = () => (catP ??= catalogue(env, url.origin));
288
+
289
+ if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
290
+ if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
291
+ if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
292
+ if (path === '/api/stats') {
293
+ if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
294
+ if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
295
+ 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' });
296
+ }
297
+ const cat = await getCat();
298
+ const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
299
+ try {
300
+ return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
301
+ } catch (error) {
302
+ 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);
303
+ }
304
+ }
305
+ if (path === '/.well-known/homie-studio.json') {
306
+ const cat = await getCat();
307
+ const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
308
+ return json({
309
+ v: 1,
310
+ kind: 'homie-studio',
311
+ name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
312
+ slug: cat.studio?.slug ?? null,
313
+ ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
314
+ site: url.origin,
315
+ studio: cat.studio?.version ?? null,
316
+ claim: await claimOf(env),
317
+ games: (cat.games ?? []).map((g) => ({
318
+ id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
319
+ page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
320
+ })),
321
+ songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
322
+ videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
323
+ // The studio's posts, for the hub (the full text is in /posts/feed.json).
324
+ posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
325
+ rooms: `${url.origin}/api/rooms`,
326
+ // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
327
+ ...(played ? { played } : {}),
328
+ }, 200, { 'access-control-allow-origin': '*' });
329
+ }
330
+ if (path === '/api/games') {
331
+ const cat = await getCat();
332
+ return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
333
+ }
334
+ if (path === '/api/rooms') {
335
+ const cat = await getCat();
336
+ const { rooms } = await roomsOf(env, cat.games ?? []);
337
+ return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*' });
338
+ }
339
+ if (path.startsWith('/media/')) {
340
+ if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
341
+ const key = decodeURIComponent(path.slice('/media/'.length));
342
+ if (!key || key.includes('..')) return new Response('not found', { status: 404 });
343
+ return mediaObject(request, env, key);
344
+ }
345
+ if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
346
+ const cat = await getCat();
347
+ if (!(cat.posts ?? []).length) return notFoundPage('This studio has no posts yet.', cat);
348
+ const posts = await postsOf(env, url.origin);
349
+ return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
350
+ }
351
+
352
+ // A page the studio made itself (site/pages) wins at its address.
353
+ if (read && (path.endsWith('/') || !path.includes('.'))) {
354
+ const cat = await getCat();
355
+ const pages = cat.site?.pages ?? [];
356
+ const want = path.endsWith('/') ? path : `${path}/`;
357
+ if (pages.includes(want)) {
358
+ if (want !== path) return Response.redirect(`${url.origin}${want}${url.search}`, 301);
359
+ const res = await env.ASSETS.fetch(new Request(`${url.origin}/_site/pages${want}index.html`));
360
+ if (res.ok) {
361
+ const top = parts[0] ?? '';
362
+ const game = (cat.games ?? []).find((g) => g.id === top);
363
+ await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
364
+ const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
365
+ return customPage(cat, await res.text(), { active });
240
366
  }
241
367
  }
242
- if (path === '/.well-known/homie-studio.json') {
243
- const cat = await catalogue(env, url.origin);
244
- const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
245
- return json({
246
- v: 1,
247
- kind: 'homie-studio',
248
- name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
249
- slug: cat.studio?.slug ?? null,
250
- site: url.origin,
251
- studio: cat.studio?.version ?? null,
252
- claim: await claimOf(env),
253
- games: (cat.games ?? []).map((g) => ({
254
- id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
255
- page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
256
- })),
257
- songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
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 } : {}),
261
- }, 200, { 'access-control-allow-origin': '*' });
368
+ }
369
+
370
+ if (parts[0] === 'music' || parts[0] === 'videos') {
371
+ const kind = parts[0];
372
+ const cat = await getCat();
373
+ const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
374
+ // A studio with no songs has no Music: the tab is gone and the page is not found (the same for videos).
375
+ if (!list.length) return notFoundPage(kind === 'music' ? 'This studio has no music yet.' : 'This studio has no videos yet.', cat);
376
+ if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
377
+ if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind, { origin: url.origin }); }
378
+ const slug = parts[1];
379
+ const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
380
+ if (parts.length === 2) {
381
+ if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`, cat);
382
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
383
+ await countVisit(request, env, ctx, `${kind}/${slug}`);
384
+ return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
385
+ }
386
+ if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
387
+ const file = await assetWithRange(request, env, url);
388
+ if (file.status === 404) return notFoundPage('Nothing here.', cat);
389
+ return file;
390
+ }
391
+ if (path === '/' || path === '/index.html') {
392
+ const cat = await getCat();
393
+ await countVisit(request, env, ctx, 'home');
394
+ shareDaily(cat, url, ctx);
395
+ const { rooms, live } = await roomsOf(env, cat.games ?? []);
396
+ return homePage(cat, { origin: url.origin, rooms, live });
397
+ }
398
+ if (['games', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
399
+ const cat = await getCat();
400
+ const has = sectionsOf(cat).some((s) => s.key === parts[0]);
401
+ if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
402
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${parts[0]}/`, 301);
403
+ await countVisit(request, env, ctx, parts[0]);
404
+ if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
405
+ const { rooms, live } = await roomsOf(env, cat.games ?? []);
406
+ return parts[0] === 'games' ? gamesPage(cat, { origin: url.origin, live }) : roomsPage(cat, { origin: url.origin, rooms });
407
+ }
408
+ if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
409
+ const cat = await getCat();
410
+ const posts = (cat.posts ?? []).length ? await postsOf(env, url.origin) : [];
411
+ const post = posts.find((p) => p.slug === parts[1]);
412
+ if (!post) return notFoundPage(`No post called "${parts[1]}" here.`, cat);
413
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/posts/${post.slug}/`, 301);
414
+ await countVisit(request, env, ctx, `posts/${post.slug}`);
415
+ return postPage(cat, post, { origin: url.origin });
416
+ }
417
+
418
+ const game = parts[0];
419
+ if (game && GAME_ID.test(game)) {
420
+ const cat = await getCat();
421
+ const meta = (cat.games ?? []).find((g) => g.id === game);
422
+ if (!meta) return fallback(request, env, url, getCat);
423
+ if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
424
+ const sub = parts.slice(1).join('/');
425
+ const max = seatsOf(meta);
426
+ const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
427
+ if (sub === '') {
428
+ await countVisit(request, env, ctx, game);
429
+ shareDaily(cat, url, ctx);
430
+ const { rooms, live } = await roomsOf(env, [meta]);
431
+ const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
432
+ return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
262
433
  }
263
- if (path === '/api/games') {
264
- const cat = await catalogue(env, url.origin);
265
- return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
434
+ if (sub === 'live') {
435
+ const { rooms, live } = await roomsOf(env, [meta]);
436
+ const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
437
+ // The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
438
+ return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: 'public', big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
266
439
  }
267
- if (path.startsWith('/media/')) {
268
- if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
269
- const key = decodeURIComponent(path.slice('/media/'.length));
270
- if (!key || key.includes('..')) return new Response('not found', { status: 404 });
271
- return mediaObject(request, env, key);
440
+ if (sub === 'credits') {
441
+ let texts = [];
442
+ try { const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/_landing/credits.json`)); if (res.ok) texts = (await res.json()).texts ?? []; } catch { texts = []; }
443
+ if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
444
+ return creditsPage(cat, meta, texts, { origin: url.origin });
272
445
  }
273
- if (parts[0] === 'music' || parts[0] === 'videos') {
274
- const kind = parts[0];
275
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
276
- const cat = await catalogue(env, url.origin);
277
- if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind); }
278
- const slug = parts[1];
279
- const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
280
- const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
281
- if (parts.length === 2) {
282
- if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
283
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
284
- await countVisit(request, env, ctx, `${kind}/${slug}`);
285
- return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
446
+ if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
447
+ const asked = url.searchParams.get('room');
448
+ if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen: true });
449
+ await countVisit(request, env, ctx, game, 'screen');
450
+ // The big screen picks its room now, so the QR it shows puts every phone in that same room.
451
+ let room = asked;
452
+ if (!room) {
453
+ try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
286
454
  }
287
- if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
288
- const file = await assetWithRange(request, env, url);
289
- if (file.status === 404) return notFoundPage('Nothing here.');
290
- return file;
455
+ const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
456
+ let qr = null;
457
+ try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
458
+ return playPage(cat, meta, { screen: true, joinUrl, qr, room });
291
459
  }
292
- if (path === '/' || path === '/index.html') {
293
- const cat = await catalogue(env, url.origin);
294
- await countVisit(request, env, ctx, 'home');
460
+ if (sub === 'play') {
461
+ const asked = url.searchParams.get('room');
462
+ if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked);
463
+ await countVisit(request, env, ctx, game, 'play');
295
464
  shareDaily(cat, url, ctx);
296
- return homePage(cat, await liveCounts(env, cat.games ?? []));
465
+ return playPage(cat, meta);
297
466
  }
298
-
299
- const game = parts[0];
300
- if (game && GAME_ID.test(game)) {
301
- const cat = await catalogue(env, url.origin);
302
- const meta = (cat.games ?? []).find((g) => g.id === game);
303
- if (!meta) return env.ASSETS.fetch(request);
304
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
305
- const sub = parts.slice(1).join('/');
306
- if (sub === '') { await countVisit(request, env, ctx, game); shareDaily(cat, url, ctx); return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0); }
307
- if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
308
- await countVisit(request, env, ctx, game, 'screen');
309
- // The big screen picks its room now, so the QR it shows puts every phone in that same room.
310
- let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
311
- if (!room) {
312
- try {
313
- const max = seatsOf(meta);
314
- room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
315
- } catch { room = null; }
316
- }
317
- const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
318
- let qr = null;
319
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
320
- return playPage(cat, meta, { screen: true, joinUrl, qr, room });
321
- }
322
- if (sub === 'play') { await countVisit(request, env, ctx, game, 'play'); shareDaily(cat, url, ctx); return playPage(cat, meta); }
323
- if (sub === 'api/lobby') {
324
- const max = seatsOf(meta);
325
- return env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
326
- }
327
- if (sub === '__net' || sub === '__watch') {
328
- if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
329
- const room = url.searchParams.get('room') || 'main';
330
- if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
331
- const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
332
- const max = seatsOf(meta);
333
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
334
- return stub.fetch(new Request(target, request));
335
- }
336
- if (sub.startsWith('__homie/')) {
337
- // A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
338
- return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
339
- }
340
- if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta);
341
- if (sub.startsWith('__game/')) return gameAsset(env, url, game, sub.slice('__game/'.length));
467
+ if (sub === 'api/lobby') return lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
468
+ if (sub === '__net' || sub === '__watch') {
469
+ if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
470
+ const room = url.searchParams.get('room') || 'main';
471
+ if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
472
+ const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
473
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
474
+ return stub.fetch(new Request(target, request));
342
475
  }
343
- const asset = await env.ASSETS.fetch(request);
344
- if (asset.status === 404) return notFoundPage('Nothing here.');
345
- return asset;
346
- },
347
- };
476
+ if (sub.startsWith('__homie/')) {
477
+ // A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
478
+ return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
479
+ }
480
+ if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
481
+ if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
482
+ }
483
+ return fallback(request, env, url, getCat);
484
+ }
485
+
486
+ /** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
487
+ async function weekOf(env, game) {
488
+ const w = await playedThisWeek(env, game);
489
+ if (!w) return null;
490
+ let peak = 0;
491
+ try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
492
+ return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
493
+ }
494
+
495
+ /** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
496
+ async function fallback(request, env, url, getCat) {
497
+ const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
498
+ if (asset.status === 404) return notFoundPage('Nothing here.', await getCat());
499
+ return asset;
500
+ }
501
+
348
502
 
349
503
  /**
350
504
  * TABLE — one public room: the netplay relay (room.mjs) for every browser in it.