jevstrudel.git / website / src / jev / sandbox / player.mjs

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';
13const refuse = (why) => {
14  document.body.textContent = why;
15  throw new Error(why);
16};

window.origin is "null" only for an opaque origin; location.origin 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);

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

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

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

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