The sandbox's end (sandbox.mjs has the why): a Strudel player with nothing of the page's, running a listener's song the page sends it.
This document has an opaque origin: it has no cookies and no storage of the site's, it cannot reach its parent except by postMessage, and its CSP (sandboxPolicy.mjs) lets it fetch only the site's files and the sample hosts. It refuses to run at all unless it is exactly that: framed, and opaque. It takes messages only from its parent, and only from the site's origin; it sends the page only data (sandboxProtocol.mjs), and its Jev calls through the page, which makes them without the visitor's session.
11import { boothData, fromPage } from '../sandboxProtocol.mjs';
window.origin is "null" only for an opaque origin; location.origin
stays the URL's, which is the site that framed it.
An opaque origin has no localStorage or sessionStorage: reading either throws. Strudel's modules keep settings there, so they get memory.
27function memoryStorage() { 28 const m = new Map(); 29 return { 30 get length() { 31 return m.size; 32 }, 33 key: (i) => [...m.keys()][i] ?? null, 34 getItem: (k) => (m.has(String(k)) ? m.get(String(k)) : null), 35 setItem: (k, v) => void m.set(String(k), String(v)), 36 removeItem: (k) => void m.delete(String(k)), 37 clear: () => m.clear(), 38 }; 39} 40for (const name of ['localStorage', 'sessionStorage']) { 41 try { 42 void window[name]; 43 } catch { 44 Object.defineProperty(window, name, { value: memoryStorage(), configurable: true }); 45 } 46}
Strudel's logger, forwarded (the page escapes it).
── Jev, through the page ─────────────────────────
55let asks = 0; 56const waiting = new Map(); 57function relayFetch(_url, init) { 58 const id = ++asks; 59 const lead = Number(init.headers?.['Jev-Section-In']); 60 const attempt = Number(init.headers?.['Jev-Attempt'] ?? 0); 61 post({ type: 'ask', id, body: init.body, lead: Number.isFinite(lead) ? lead : null, attempt }); 62 return new Promise((resolve, reject) => waiting.set(id, { resolve, reject })); 63}
65const [core, transpiler, webaudio, { prebakeSounds }, jevCore, { boothStore }, preload, meter, ask] = await Promise.all([ 66 import('@strudel/core'), 67 import('@strudel/transpiler'), 68 import('@strudel/webaudio'), 69 import('../../repl/prebakeSounds.mjs'), 70 import('../jevCore.mjs'), 71 import('../boothStore.mjs'), 72 import('../preload.mjs'), 73 import('../meter.mjs'), 74 import('../ask.mjs'), 75]); 76ask.setRelayFetch(relayFetch);
What songs can call: the REPL's sound and pattern modules, and Jev. Not the ones that reach devices or other programs (MIDI, OSC, serial, MQTT, gamepad, motion) or draw over the page (hydra): the sandbox is sound only.
82await core.evalScope( 83 import('@strudel/core'), 84 import('@strudel/mini'), 85 import('@strudel/tonal'), 86 import('@strudel/xen'), 87 import('@strudel/edo'), 88 import('@strudel/webaudio'), 89 import('@strudel/soundfonts'), 90 import('@strudel/draw'), 91 import('@strudel/tidal'), 92 import('../jev.mjs'), 93); 94const sounds = prebakeSounds();
96const OPENING_WAIT_MS = 4000; // as the page waits (useReplContext) 97let run = 0; 98const repl = core.repl({ 99 defaultOutput: webaudio.webaudioOutput, 100 getTime: webaudio.getAudioContextCurrentTime, 101 transpiler: transpiler.transpiler, 102 beforeEval: async () => { 103 run += 1; 104 boothStore.beginEval(); 105 await sounds; 106 }, 107 beforeStart: async () => { 108 const written = preload.songAsWritten(repl.scheduler.pattern); 109 jevCore.setWrittenParts(written); 110 await Promise.all([preload.preloadSamples(written), jevCore.askOpening(OPENING_WAIT_MS)]); 111 }, 112 afterEval: () => { 113 boothStore.commitEval(); 114 }, 115 onToggle: () => report(), 116 onEvalError: (e) => report(e), 117});
jev.mjs and meter.mjs read the scheduler here, as in the page
119globalThis.strudelMirror = { repl };
the page's number for the last play it sent, so it can tell this play's start (or failure) from the song before it
123let playRun = 0; 124function report(error) { 125 const s = repl.scheduler; 126 post({ 127 type: 'state', 128 run: playRun, 129 playing: Boolean(s.started), 130 cycle: s.started ? s.now() : null, 131 cps: Number.isFinite(s.cps) ? s.cps : null, 132 error: error ? String(error.message ?? error).slice(0, 500) : null, 133 }); 134} 135setInterval(() => { 136 if (!repl.scheduler.started) return; 137 report(); 138 post({ type: 'levels', levels: meter.latest() }); 139}, 100);
── audio ─────────────────────────────────────────
Browsers start audio only after a click. The page's click reaches here
as resume (the frame is allowed autoplay); where that is not enough,
the frame shows its own button, and the page shows the frame.
155const ctx = webaudio.getAudioContext(); 156let audioStarted = null; 157const start = document.getElementById('start'); 158function audioState() { 159 post({ type: 'audio', state: ctx.state }); 160 if (start) start.hidden = ctx.state === 'running' || pending === null; 161} 162ctx.addEventListener('statechange', () => { 163 audioState(); 164 if (ctx.state === 'running') playPending(); 165}); 166function resume() { 167 ctx.resume().catch(() => {}); 168} 169start?.addEventListener('click', resume);
171let pending = null;
the recorded performance the pending play replays, or null (Jev plays live)
173let pendingReplay = null; 174async function playPending() { 175 if (pending === null || ctx.state !== 'running') return; 176 const code = pending; 177 pending = null; 178 // every jev() the evaluation declares plays it (jevCore.mjs, Replay), as 179 // a site song's replay does in the page 180 jevCore.setReplay(pendingReplay); 181 audioState(); 182 audioStarted ??= webaudio.initAudio(); 183 await audioStarted; 184 // songs count cycles from 0; only stop() resets the count 185 repl.stop(); 186 await repl.evaluate(code); 187}
189window.addEventListener('message', (e) => { 190 if (e.source !== parent || e.origin !== SITE) return; 191 const m = fromPage(e.data); 192 if (!m) return; 193 switch (m.type) { 194 case 'play': 195 playRun = m.run; 196 pending = m.code; 197 pendingReplay = m.replay; 198 repl.stop(); 199 resume(); 200 audioState(); 201 playPending(); 202 return; 203 case 'stop': 204 pending = null; 205 audioState(); 206 repl.stop(); 207 return; 208 case 'resume': 209 resume(); 210 return; 211 case 'react': 212 boothStore.getSnapshot().booth?.react(m.segment, m.kind); 213 return; 214 case 'answer': { 215 const w = waiting.get(m.id); 216 if (!w) return; 217 waiting.delete(m.id); 218 if (m.error) w.reject(new TypeError(m.error)); 219 else w.resolve(new Response(m.body, { status: m.status, headers: m.headers })); 220 return; 221 } 222 } 223}); 224 225post({ type: 'ready' });