1// The sandbox's end (sandbox.mjs has the why): a Strudel player with 2// nothing of the page's, running a listener's song the page sends it. 3// 4// This document has an opaque origin: it has no cookies and no storage of 5// the site's, it cannot reach its parent except by postMessage, and its CSP 6// (sandboxPolicy.mjs) lets it fetch only the site's files and the sample 7// hosts. It refuses to run at all unless it is exactly that: framed, and 8// opaque. It takes messages only from its parent, and only from the site's 9// origin; it sends the page only data (sandboxProtocol.mjs), and its Jev 10// calls through the page, which makes them without the visitor's session. 11import { boothData, fromPage } from '../sandboxProtocol.mjs'; 12 13const refuse = (why) => { 14 document.body.textContent = why; 15 throw new Error(why); 16}; 17// `window.origin` is "null" only for an opaque origin; `location.origin` 18// stays the URL's, which is the site that framed it. 19if (window.origin !== 'null') refuse('this page only plays inside the site, in a sandbox'); 20if (window.parent === window) refuse('this page only plays inside the site'); 21const SITE = location.origin; 22const parent = window.parent; 23const post = (m) => parent.postMessage(m, SITE); 24 25// An opaque origin has no localStorage or sessionStorage: reading either 26// 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} 47 48// Strudel's logger, forwarded (the page escapes it). 49document.addEventListener('strudel.log', (e) => { 50 const { message, type } = e.detail ?? {}; 51 if (typeof message === 'string') post({ type: 'log', message: message.slice(0, 500), kind: type ?? '' }); 52}); 53 54// ── 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} 64 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); 77 78// What songs can call: the REPL's sound and pattern modules, and Jev. 79// Not the ones that reach devices or other programs (MIDI, OSC, serial, 80// MQTT, gamepad, motion) or draw over the page (hydra): the sandbox is 81// 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(); 95 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}); 118// jev.mjs and meter.mjs read the scheduler here, as in the page 119globalThis.strudelMirror = { repl }; 120 121// the page's number for the last play it sent, so it can tell this play's 122// 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); 140 141let boothQueued = false; 142boothStore.subscribe(() => { 143 if (boothQueued) return; 144 boothQueued = true; 145 queueMicrotask(() => { 146 boothQueued = false; 147 post({ type: 'booth', run, booth: boothData(boothStore.getSnapshot().booth) }); 148 }); 149}); 150 151// ── audio ───────────────────────────────────────── 152// Browsers start audio only after a click. The page's click reaches here 153// as `resume` (the frame is allowed autoplay); where that is not enough, 154// 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); 170 171let pending = null; 172// 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} 188 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' });