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';

jevstrudel: the Worker (../worker) runs beside the dev server under wrangler dev; its port is set in its own config. JEV_WORKER_PORT points a second dev server (another worktree's, on astro dev --port …) at its 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}` };

jevstrudel: the songs and samples live outside the site (../songs, ../samples) and reach pages through build-time globs (src/jev/songs.mjs), whose server-side results Vite does not invalidate when those files change: a rescored SPEC kept serving its old frontmatter (2026-09-24). 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';

jevstrudel: the sandbox listeners' songs play in (src/jev/sandbox.mjs) has an opaque origin, so everything it loads from the site is a cross-origin request with Origin: null. Its headers are in src/jev/sandboxPolicy.mjs: the build writes them into _headers, which Cloudflare applies to the static files (jevHeaders), and the dev server 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}

In dev, the sandbox's scripts come from Vite under many paths, not _astro/: the page's own sources, dependencies and the repo's packages and samples get CORS, and nothing else of the working tree (the Worker's 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';
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;

this rehype plugin fixes relative links it works by prepending the base + page path to anchor links and by prepending the base path to other relative links starting with / this is necessary when using a base href like <base href={base} /> examples with base as "mybase": #gain -> /mybase/learn/effects/#gain /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};

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});