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