jevstrudel.git / worker / src / catalog.ts
1// The site's own songs, as the hosted MCP lists and reads them: files the
2// build writes (website/src/pages/jev/catalog.json.js and
3// catalog/[theme]/[song].json.js), read through the ASSETS binding in
4// production and preview, or from the dev server in dev (SONGS_URL's
5// origin), as votes.ts reads the song list.
6import type { Env } from './env';
7
8export const CATALOG_PATH = '/jev/catalog.json';
9export const SITE_SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
10
11export type CatalogEntry = { id: string; title: string; theme: string; description: string; art: number | null; seconds: number | null };
12export type SiteSong = { id: string; title: string; theme: string; description: string; spec: string; code: string };
13
14export async function siteFile(env: Env, path: string): Promise<Response> {
15  if (env.SONGS_URL) return fetch(new URL(path, env.SONGS_URL));
16  if (env.ASSETS) return env.ASSETS.fetch(new Request(new URL(path, 'https://assets.invalid')));
17  throw new Error('no site files: neither SONGS_URL nor ASSETS is bound');
18}
19
20export async function catalog(env: Env): Promise<CatalogEntry[]> {
21  const res = await siteFile(env, CATALOG_PATH);
22  if (!res.ok) throw new Error(`the site's song list answered ${res.status}`);
23  const { songs } = (await res.json()) as { songs?: CatalogEntry[] };
24  if (!Array.isArray(songs)) throw new Error("the site's song list is malformed");
25  return songs;
26}
27
28// One site song whole, or null when the site has none by that id.
29export async function siteSong(env: Env, id: string): Promise<SiteSong | null> {
30  if (!SITE_SONG.test(id)) return null;
31  const res = await siteFile(env, `/jev/catalog/${id}.json`);
32  if (res.status === 404) return null;
33  if (!res.ok) throw new Error(`the site's song ${id} answered ${res.status}`);
34  const song = (await res.json()) as SiteSong;
35  return typeof song?.code === 'string' ? song : null;
36}