@homie-rocks/studio 0.6.0 → 0.8.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 +45 -1
- package/bin/homie-studio.mjs +163 -7
- package/lib/build.mjs +38 -12
- package/lib/check.mjs +50 -4
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/port-check.mjs +26 -4
- package/lib/progress.mjs +420 -0
- package/lib/scaffold.mjs +68 -5
- package/lib/site.mjs +437 -0
- package/lib/studio.mjs +1 -1
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +297 -143
- package/worker/pages.mjs +149 -212
- package/worker/room.mjs +28 -1
- package/worker/site.mjs +1052 -0
- package/worker/stats.mjs +8 -5
- package/worker/version.mjs +2 -0
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* /<game>/
|
|
11
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 =
|
|
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':
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
264
|
-
const
|
|
265
|
-
|
|
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 (
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
if (!
|
|
271
|
-
return
|
|
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 (
|
|
274
|
-
const
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
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 (
|
|
293
|
-
const
|
|
294
|
-
|
|
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
|
|
465
|
+
return playPage(cat, meta);
|
|
297
466
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|