An easy-to-use platform for EEG experimentation in the classroom
1/**
2 * Pyodide Web Worker — local node_modules implementation.
3 *
4 * Loading strategy
5 * ----------------
6 * pyodide.mjs is imported via Vite's `?url` suffix, which gives us an
7 * /@fs/... URL in dev. We use dynamic import() from that URL — this works
8 * because import() bypasses Vite's SPA fallback (only fetch() is affected).
9 *
10 * The lock file is embedded via `?raw` to avoid an HTTP fetch that Vite
11 * intercepts. A blob URL is created from the embedded JSON so loadPyodide
12 * can "fetch" it from memory.
13 *
14 * Package whl files (numpy, scipy, etc.) live in
15 * src/renderer/utils/webworker/src/pyodide/ and are served via a custom
16 * Electron protocol scheme (pyodide://) registered in the main process.
17 * This requires no network socket and works in both dev and production.
18 *
19 * MNE and its pure-Python deps: JS fetches each .whl via pyodide://, writes
20 * the bytes into Pyodide's emscripten FS (/tmp/), then micropip installs from
21 * emfs:///tmp/ — micropip only accepts http/https/emfs URLs, not custom schemes.
22 */
23
24// ?url → Vite resolves to /@fs/... in dev; asset URL in prod.
25// ?raw → Vite embeds file content as a string (no HTTP fetch at runtime).
26import pyodideMjsUrl from 'pyodide/pyodide.mjs?url';
27import lockFileRaw from 'pyodide/pyodide-lock.json?raw';
28
29// Custom Electron protocol scheme registered in src/main/index.ts.
30// Serves files from src/renderer/utils/webworker/src/ (dev) or
31// resources/webworker/src/ (prod) without opening a network socket.
32const PYODIDE_ASSET_BASE = 'pyodide://host';
33
34const pyodideReadyPromise = (async () => {
35 const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl);
36
37 // Wrap the embedded lock file in a blob URL so loadPyodide can "fetch" it
38 // without making an HTTP request that Vite would intercept and transform.
39 const lockBlob = new Blob([lockFileRaw], { type: 'application/json' });
40 const lockFileURL = URL.createObjectURL(lockBlob);
41
42 // packageBaseUrl tells pyodide's PackageManager where to fetch .whl files.
43 // This is the correct option — NOT indexURL, which is for the runtime files
44 // (WASM, stdlib) that are already loaded via import.meta.url from node_modules.
45 const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`;
46
47 const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl });
48 URL.revokeObjectURL(lockFileURL);
49
50 // Load scientific packages from local whl files via the asset server.
51 // checkIntegrity: false skips SHA256 verification — hashes in the npm lock
52 // file may not match the CDN-downloaded whl files we're actually serving.
53 await pyodide.loadPackage(
54 ['numpy', 'scipy', 'matplotlib', 'pandas', 'pillow'],
55 { checkIntegrity: false }
56 );
57
58 // Set matplotlib backend before any imports so it takes effect on first import.
59 // Must be 'agg' (non-interactive, buffer-based) — web workers have no DOM,
60 // so WebAgg fails with "cannot import name 'document' from 'js'".
61 await pyodide.runPythonAsync(
62 'import os; os.environ["MPLBACKEND"] = "agg"'
63 );
64
65 // Load micropip so we can install MNE and its pure-Python deps.
66 await pyodide.loadPackage('micropip', { checkIntegrity: false });
67 const micropip = pyodide.pyimport('micropip');
68
69 // MNE + pure-Python deps: micropip only accepts http://, https://, emfs://,
70 // and relative paths — it rejects the pyodide:// custom scheme.
71 // Workaround: JS-fetch each .whl via the protocol handler (which supports it),
72 // write the bytes into Pyodide's emscripten virtual FS, then install via emfs://.
73 const manifest = await fetch(`${PYODIDE_ASSET_BASE}/packages/manifest.json`)
74 .then((r) => r.json());
75
76 for (const { filename } of Object.values(manifest)) {
77 const buffer = await fetch(`${PYODIDE_ASSET_BASE}/packages/${filename}`)
78 .then((r) => r.arrayBuffer());
79 pyodide.FS.writeFile(`/tmp/${filename}`, new Uint8Array(buffer));
80 }
81
82 await micropip.install(
83 Object.values(manifest).map(({ filename }) => `emfs:///tmp/${filename}`)
84 );
85
86 return pyodide;
87})();
88
89self.onmessage = async (event) => {
90 // Propagate init failures back to the main thread rather than hanging silently.
91 let pyodide;
92 try {
93 pyodide = await pyodideReadyPromise;
94 } catch (error) {
95 self.postMessage({ error: `Pyodide init failed: ${error.message}` });
96 return;
97 }
98
99 const { data, plotKey, ...context } = event.data;
100
101 // Expose context values as globals so Python can access them via the js module.
102 for (const [key, value] of Object.entries(context)) {
103 self[key] = value;
104 }
105
106 try {
107 self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey });
108 } catch (error) {
109 self.postMessage({ error: error.message, plotKey });
110 }
111};