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