An easy-to-use platform for EEG experimentation in the classroom
at main 111 lines 4.7 kB view raw
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};