jevstrudel.git / website / src / jev / sandbox / player.mjs
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' });