jevstrudel.git / website / astro.config.mjs
1import { siteDescription, siteName, themeColor } from './src/jev/site.mjs';
2import { readFileSync } from 'node:fs';
3import { fileURLToPath } from 'node:url';
4
5// jevstrudel: the Worker (../worker) runs beside the dev server under
6// `wrangler dev`; its port is set in its own config. JEV_WORKER_PORT points
7// a second dev server (another worktree's, on `astro dev --port …`) at its
8// own Worker instead.
9const workerPort =
10  process.env.JEV_WORKER_PORT ?? JSON.parse(readFileSync(new URL('../worker/wrangler.json', import.meta.url))).dev.port;
11const toWorker = { target: `http://localhost:${workerPort}` };
12
13// jevstrudel: the songs and samples live outside the site (../songs,
14// ../samples) and reach pages through build-time globs (src/jev/songs.mjs),
15// whose server-side results Vite does not invalidate when those files
16// change: a rescored SPEC kept serving its old frontmatter (2026-09-24).
17// In dev, any change there invalidates the server modules and reloads.
18const songsRoots = ['../songs/', '../samples/'].map((d) => fileURLToPath(new URL(d, import.meta.url)));
19function jevSongsReload() {
20  return {
21    name: 'jev-songs-reload',
22    apply: 'serve',
23    configureServer(server) {
24      server.watcher.add(songsRoots);
25      // Invalidate the changed file's modules and everything that imports
26      // them, up to the pages. (Invalidating the whole graph also drops
27      // Astro's own runtime: "createAstro is not a function".)
28      const invalidateUp = (graph, mods, seen = new Set()) => {
29        for (const mod of mods) {
30          if (!mod || seen.has(mod)) continue;
31          seen.add(mod);
32          graph.invalidateModule(mod);
33          invalidateUp(graph, mod.importers, seen);
34        }
35      };
36      const changed = (file) => {
37        if (!songsRoots.some((root) => file.startsWith(root))) return;
38        const graphs = Object.values(server.environments ?? {}).map((env) => env.moduleGraph);
39        for (const graph of graphs.length ? graphs : [server.moduleGraph]) {
40          invalidateUp(graph, graph.getModulesByFile(file) ?? []);
41          // a new or deleted file changes a glob's result: its importers
42          for (const [id, mod] of graph.idToModuleMap ?? []) {
43            if (id.includes('/src/jev/songs.mjs') || id.includes('/pages/jev-samples/')) invalidateUp(graph, [mod]);
44          }
45        }
46        server.ws.send({ type: 'full-reload' });
47      };
48      for (const event of ['change', 'add', 'unlink']) server.watcher.on(event, changed);
49    },
50  };
51}
52import { writeFileSync } from 'node:fs';
53import { headersFile, SANDBOX_PAGE, sandboxHeaders } from './src/jev/sandboxPolicy.mjs';
54
55// jevstrudel: the sandbox listeners' songs play in (src/jev/sandbox.mjs)
56// has an opaque origin, so everything it loads from the site is a
57// cross-origin request with `Origin: null`. Its headers are in
58// src/jev/sandboxPolicy.mjs: the build writes them into `_headers`, which
59// Cloudflare applies to the static files (jevHeaders), and the dev server
60// sends them itself (jevSandboxDev).
61function jevHeaders() {
62  return {
63    name: 'jev-headers',
64    hooks: {
65      'astro:build:done': ({ dir }) => writeFileSync(new URL('_headers', dir), headersFile()),
66    },
67  };
68}
69// In dev, the sandbox's scripts come from Vite under many paths, not
70// _astro/: the page's own sources, dependencies and the repo's packages
71// and samples get CORS, and nothing else of the working tree (the Worker's
72// local state, say) is readable from an opaque origin.
73const repoRoot = fileURLToPath(new URL('..', import.meta.url));
74const sandboxReadable = [
75  '/@vite/',
76  '/@id/',
77  '/src/',
78  '/node_modules/',
79  '/jev-samples/',
80  ...['website/', 'packages/', 'samples/', 'node_modules/'].map((d) => `/@fs${repoRoot}${d}`),
81];
82function jevSandboxDev() {
83  return {
84    name: 'jev-sandbox-dev',
85    apply: 'serve',
86    configureServer(server) {
87      server.middlewares.use((req, res, next) => {
88        const path = decodeURIComponent((req.url ?? '').split('?')[0]);
89        if (path === SANDBOX_PAGE || path === SANDBOX_PAGE.slice(0, -1)) {
90          for (const [name, value] of Object.entries(sandboxHeaders())) res.setHeader(name, value);
91        } else if (
92          req.headers.origin === 'null' &&
93          (req.method === 'GET' || req.method === 'HEAD') &&
94          !path.includes('/../') &&
95          sandboxReadable.some((prefix) => path.startsWith(prefix))
96        ) {
97          res.setHeader('Access-Control-Allow-Origin', '*');
98        }
99        next();
100      });
101    },
102  };
103}
104import { defineConfig } from 'astro/config';
105import react from '@astrojs/react';
106import mdx from '@astrojs/mdx';
107import remarkToc from 'remark-toc';
108import rehypeSlug from 'rehype-slug';
109import rehypeAutolinkHeadings from 'rehype-autolink-headings';
110import rehypeUrls from 'rehype-urls';
111import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
112
113import tailwind from '@astrojs/tailwind';
114import AstroPWA from '@vite-pwa/astro';
115
116import process from 'node:process';
117
118const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
119const base = process.env.BASE_PATH || ''; // base path of the strudel site
120
121const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
122
123// this rehype plugin fixes relative links
124// it works by prepending the base + page path to anchor links
125// and by prepending the base path to other relative links starting with /
126// this is necessary when using a base href like <base href={base} />
127// examples with base as "mybase":
128//   #gain -> /mybase/learn/effects/#gain
129//   /some/page -> /mybase/some/page
130function relativeURLFix() {
131  return (tree, file) => {
132    const chunks = file.history[0].split('/src/pages/'); // file.history[0] is the file path
133    const path = chunks[chunks.length - 1].slice(0, -4); // only path inside src/pages, without .mdx
134    return rehypeUrls((url) => {
135      let newHref = baseNoTrailing;
136      if (url.href.startsWith('#')) {
137        // special case: a relative anchor link to the current page
138        newHref += `/${path}/${url.href}`;
139      } else if (url.href.startsWith('/')) {
140        // any other relative url starting with /
141        newHref += url.pathname;
142        if (url.pathname.indexOf('.') == -1) {
143          // append trailing slash to resource only if there is no file extension
144          newHref += url.pathname.endsWith('/') ? '' : '/';
145        }
146        newHref += url.search || '';
147        newHref += url.hash || '';
148      } else {
149        // leave this URL alone
150        return;
151      }
152      // console.log(url.href + ' -> ', newHref);
153      return newHref;
154    })(tree);
155  };
156}
157const options = {
158  // See https://mdxjs.com/advanced/plugins
159  remarkPlugins: [
160    remarkToc,
161    // E.g. `remark-frontmatter`
162  ],
163  rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'append' }], relativeURLFix],
164};
165
166// https://astro.build/config
167export default defineConfig({
168  // the dev server's toolbar, not wanted here (the user, 2026-09-27)
169  devToolbar: { enabled: false },
170  integrations: [
171    jevHeaders(),
172    react(),
173    mdx(options),
174    tailwind(),
175    AstroPWA({
176      experimental: { directoryAndTrailingSlashHandler: true },
177      // jevstrudel: a new deploy waits until the visitor presses update in
178      // the release-notes modal (src/jev/UpdateModal.jsx), rather than
179      // installing silently and taking effect on some later reload.
180      registerType: 'prompt',
181      injectRegister: 'auto',
182      workbox: {
183        maximumFileSizeToCacheInBytes: 4194304, // 4MB
184        // read live by the update modal, never from the precache
185        globIgnores: ['**/release-notes.json'],
186        globPatterns: ['**/*.{js,css,html,ico,png,svg,json,wav,mp3,ogg,ttf,woff2,TTF,otf}'],
187        // The static pages never vary by query, but the page reads its own
188        // (?performance=, ?party=, ?listener=, ?session=). Workbox otherwise
189        // keeps unknown parameters when matching the precache, so a song page
190        // with one missed and fell back to "/", the featured song (2026-09-26).
191        ignoreURLParametersMatching: [/.*/],
192        // No fallback page. Every page of the site is precached, so offline
193        // still works; anything else goes to the network. The default answered
194        // every uncached navigation with "/", which swallowed the Worker's own
195        // pages: the OAuth consent at /jev/oauth/authorize showed the REPL
196        // (2026-09-26), as ?performance= links had shown the featured song.
197        navigateFallback: null,
198        runtimeCaching: [
199          {
200            urlPattern: ({ url }) =>
201              [
202                /^https:\/\/raw\.githubusercontent\.com\/.*/i,
203                /^https:\/\/strudel\.b-cdn\.net\/.*/i,
204                /^https:\/\/freesound\.org\/.*/i,
205                /^https:\/\/cdn\.freesound\.org\/.*/i,
206                /^https:\/\/shabda\.ndre\.gr\/.*/i,
207              ].some((regex) => regex.test(url)),
208            handler: 'CacheFirst',
209            options: {
210              cacheName: 'external-samples',
211              expiration: {
212                maxEntries: 5000,
213                maxAgeSeconds: 60 * 60 * 24 * 30, // <== 14 days
214              },
215              cacheableResponse: {
216                statuses: [0, 200],
217              },
218            },
219          },
220        ],
221      },
222      devOptions: {
223        enabled: false,
224      },
225      manifest: {
226        includeAssets: ['favicon.ico', 'icons/apple-icon-180.png'],
227        name: siteName,
228        short_name: siteName,
229        description: siteDescription,
230        theme_color: themeColor,
231        icons: [
232          {
233            src: 'icons/manifest-icon-192.maskable.png',
234            sizes: '192x192',
235            type: 'image/png',
236            purpose: 'any',
237          },
238          {
239            src: 'icons/manifest-icon-192.maskable.png',
240            sizes: '192x192',
241            type: 'image/png',
242            purpose: 'maskable',
243          },
244          {
245            src: 'icons/manifest-icon-512.maskable.png',
246            sizes: '512x512',
247            type: 'image/png',
248            purpose: 'any',
249          },
250          {
251            src: 'icons/manifest-icon-512.maskable.png',
252            sizes: '512x512',
253            type: 'image/png',
254            purpose: 'maskable',
255          },
256        ],
257      },
258    }),
259  ],
260  site,
261  base,
262  vite: {
263    plugins: [bundleAudioWorkletPlugin(), jevSongsReload(), jevSandboxDev()],
264    server: {
265      // jevstrudel: the paths the Worker answers in production go to the
266      // same Worker code locally: the Jev relay, the votes, listening (reactions
267      // and performances), listening parties, the lobby, listeners' content,
268      // what the site stores (the data tab), the hosted MCP with its OAuth,
269      // and the dev MCP hub.
270      proxy: {
271        '/jev/v1/systemone': toWorker,
272        '/jev/votes': toWorker,
273        '/jev/auth/': toWorker,
274        // /jev/me/tabs is a signed-in tab's WebSocket to its user's hub
275        '/jev/me/': { ...toWorker, ws: true },
276        // the hosted MCP and its OAuth (worker/src/oauth.ts, hosted-mcp.ts)
277        '/jev/mcp': toWorker,
278        '/jev/oauth/': toWorker,
279        '/.well-known/oauth-': toWorker,
280        '/jev/reactions': toWorker,
281        '/jev/performances': toWorker,
282        '/jev/listeners/': toWorker,
283        '/jev/party': { ...toWorker, ws: true },
284        // live parties (a different prefix: '/jev/party' does not match '/jev/parties')
285        '/jev/parties': toWorker,
286        '/jev/lobby': { ...toWorker, ws: true },
287        // everything the site stores, for the jev panel's data tab (worker/src/data.ts)
288        '/jev/data': toWorker,
289        '/strudel/status': toWorker,
290        '/strudel/ws': { ...toWorker, ws: true },
291      },
292    },
293    ssr: {
294      // Example: Force a broken package to skip SSR processing, if needed
295      // external: ['fraction.js'], // https://github.com/infusion/Fraction.js/issues/51
296    },
297  },
298  server: {
299    // jevstrudel: 4321, Astro's default, is kept free; 8080 is Gerrit here
300    port: 4322,
301    // these are needed for dough, which uses shared memory
302    // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
303    // this is only for the dev server, so the server strudel runs on, also needs those
304    headers: {
305      'Cross-Origin-Opener-Policy': 'same-origin',
306      'Cross-Origin-Embedder-Policy': 'credentialless',
307    },
308  },
309});