An easy-to-use platform for EEG experimentation in the classroom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip

+314 -197
+173 -78
internals/scripts/InstallMNE.mjs
··· 1 1 #!/usr/bin/env node 2 2 /** 3 - * Downloads MNE-Python and its pure-Python dependencies from PyPI as wheel 4 - * files for offline use with Pyodide. Binary dependencies (numpy, scipy, 5 - * matplotlib, pandas) are already included via the `pyodide` npm package and 6 - * do NOT need to be downloaded here. 3 + * Downloads everything MNE-Python needs to run offline inside Pyodide: 7 4 * 8 - * Downloaded wheels are saved to: 9 - * src/renderer/utils/pyodide/src/packages/ 5 + * Part 1 — Pyodide binary packages (from the Pyodide CDN) 6 + * Reads pyodide-lock.json that was extracted by InstallPyodide.mjs, 7 + * recursively resolves all dependencies of numpy / scipy / matplotlib / 8 + * pandas, and downloads each .whl (or .zip) into the same /pyodide/ 9 + * directory as the runtime. loadPackage() will find them locally and 10 + * will not need to reach the CDN at runtime. 10 11 * 11 - * A manifest.json is written there so the web worker knows which filenames 12 - * to pass to micropip.install() at startup. 12 + * Part 2 — Pure-Python packages (from PyPI) 13 + * MNE itself and its pure-Python dependencies (pooch, tqdm, platformdirs) 14 + * are not bundled with Pyodide. These are downloaded as py3-none-any 15 + * wheels into src/renderer/utils/pyodide/src/packages/ and installed via 16 + * micropip at worker startup. A manifest.json is written there so the 17 + * worker knows the exact filenames. 13 18 * 14 19 * Usage: node internals/scripts/InstallMNE.mjs 20 + * Runs automatically via the postinstall npm hook. 15 21 */ 16 22 17 23 import fs from 'fs'; ··· 19 25 import path from 'path'; 20 26 import chalk from 'chalk'; 21 27 22 - const PACKAGES_DIR = path.resolve( 23 - 'src/renderer/utils/pyodide/src/packages' 24 - ); 28 + // --------------------------------------------------------------------------- 29 + // Paths 30 + // --------------------------------------------------------------------------- 31 + 32 + const PYODIDE_DIR = path.resolve('src/renderer/utils/pyodide/src/pyodide'); 33 + const LOCK_FILE = path.join(PYODIDE_DIR, 'pyodide-lock.json'); 34 + 35 + const PACKAGES_DIR = path.resolve('src/renderer/utils/pyodide/src/packages'); 25 36 const MANIFEST_FILE = path.join(PACKAGES_DIR, 'manifest.json'); 26 37 27 - /** 28 - * Pure-Python packages required by MNE that are not bundled with Pyodide. 29 - * Each entry is resolved against the PyPI JSON API to find the latest 30 - * pure-Python wheel (py3-none-any or py2.py3-none-any). 31 - */ 32 - const PACKAGES_TO_DOWNLOAD = [ 33 - 'mne', 34 - 'pooch', 35 - 'tqdm', 36 - 'platformdirs', 37 - ]; 38 + // --------------------------------------------------------------------------- 39 + // Root packages whose full transitive dependency tree we need from Pyodide CDN 40 + // --------------------------------------------------------------------------- 41 + 42 + const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas']; 43 + 44 + // --------------------------------------------------------------------------- 45 + // Pure-Python packages to download from PyPI (not bundled with Pyodide) 46 + // --------------------------------------------------------------------------- 47 + 48 + const PYPI_PACKAGES = ['mne', 'pooch', 'tqdm', 'platformdirs']; 38 49 39 50 // --------------------------------------------------------------------------- 40 - // Network helpers 51 + // Shared network helpers 41 52 // --------------------------------------------------------------------------- 42 53 43 - function httpsGet(url) { 54 + function downloadBinary(url, dest) { 44 55 return new Promise((resolve, reject) => { 45 - const req = https.get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 46 - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 47 - resolve(httpsGet(res.headers.location)); 48 - return; 49 - } 50 - if (res.statusCode !== 200) { 51 - reject(new Error(`HTTP ${res.statusCode} for ${url}`)); 52 - return; 53 - } 54 - let body = ''; 55 - res.setEncoding('utf8'); 56 - res.on('data', (chunk) => { body += chunk; }); 57 - res.on('end', () => resolve(body)); 58 - res.on('error', reject); 59 - }); 60 - req.on('error', reject); 56 + const doGet = (reqUrl) => { 57 + https 58 + .get(reqUrl, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 59 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 60 + doGet(res.headers.location); 61 + return; 62 + } 63 + if (res.statusCode !== 200) { 64 + reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`)); 65 + return; 66 + } 67 + const file = fs.createWriteStream(dest); 68 + res.pipe(file); 69 + file.on('finish', () => file.close(resolve)); 70 + file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 71 + }) 72 + .on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 73 + }; 74 + doGet(url); 61 75 }); 62 76 } 63 77 64 - function downloadBinary(url, dest) { 78 + function httpsGetText(url) { 65 79 return new Promise((resolve, reject) => { 66 - const doGet = (reqUrl) => { 67 - https.get(reqUrl, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 80 + https 81 + .get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 68 82 if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 69 - doGet(res.headers.location); 83 + resolve(httpsGetText(res.headers.location)); 70 84 return; 71 85 } 72 86 if (res.statusCode !== 200) { 73 - reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`)); 87 + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); 74 88 return; 75 89 } 76 - const file = fs.createWriteStream(dest); 77 - res.pipe(file); 78 - file.on('finish', () => file.close(resolve)); 79 - file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 80 - }).on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 81 - }; 82 - doGet(url); 90 + let body = ''; 91 + res.setEncoding('utf8'); 92 + res.on('data', (c) => { body += c; }); 93 + res.on('end', () => resolve(body)); 94 + res.on('error', reject); 95 + }) 96 + .on('error', reject); 83 97 }); 84 98 } 85 99 86 100 // --------------------------------------------------------------------------- 87 - // PyPI helpers 101 + // Part 1 — Pyodide binary packages 102 + // --------------------------------------------------------------------------- 103 + 104 + /** 105 + * Recursively walks the `depends` graph in the lock file and returns every 106 + * package entry (including root packages) needed to satisfy the given roots. 107 + * Package name matching is case-insensitive. 108 + */ 109 + function resolveAllDeps(lockPackages, rootNames) { 110 + // Build a lowercase → original-key index for case-insensitive lookup. 111 + const index = {}; 112 + for (const key of Object.keys(lockPackages)) { 113 + index[key.toLowerCase()] = key; 114 + } 115 + 116 + const resolved = new Set(); 117 + const queue = rootNames.map((n) => n.toLowerCase()); 118 + 119 + while (queue.length) { 120 + const lower = queue.shift(); 121 + const key = index[lower]; 122 + if (!key || resolved.has(key)) continue; 123 + resolved.add(key); 124 + for (const dep of lockPackages[key].depends ?? []) { 125 + queue.push(dep.toLowerCase()); 126 + } 127 + } 128 + 129 + return [...resolved].map((key) => lockPackages[key]); 130 + } 131 + 132 + async function downloadPyodidePackages() { 133 + if (!fs.existsSync(LOCK_FILE)) { 134 + console.warn( 135 + chalk.yellow( 136 + ' ⚠ pyodide-lock.json not found — run `npm install` first to ' + 137 + 'extract the Pyodide runtime, then re-run this script.' 138 + ) 139 + ); 140 + return; 141 + } 142 + 143 + const lockData = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8')); 144 + 145 + // The lock file's info.version may be an internal dev label (e.g. "0.28.0.dev0"). 146 + // Always derive the CDN URL from the installed npm package version instead. 147 + const npmPkgPath = path.resolve('node_modules/pyodide/package.json'); 148 + const cdnVersion = fs.existsSync(npmPkgPath) 149 + ? JSON.parse(fs.readFileSync(npmPkgPath, 'utf8')).version 150 + : lockData.info.version; 151 + const cdnBase = `https://cdn.jsdelivr.net/pyodide/v${cdnVersion}/full/`; 152 + 153 + const allPkgs = resolveAllDeps(lockData.packages, PYODIDE_ROOT_PACKAGES); 154 + 155 + console.log( 156 + chalk.blue.bold( 157 + `Downloading ${allPkgs.length} Pyodide packages from CDN (v${cdnVersion})…` 158 + ) 159 + ); 160 + 161 + for (const pkg of allPkgs) { 162 + process.stdout.write(chalk.blue(` ${pkg.name ?? pkg.file_name}: `)); 163 + 164 + const dest = path.join(PYODIDE_DIR, pkg.file_name); 165 + if (fs.existsSync(dest)) { 166 + console.log(chalk.gray('already present, skipping')); 167 + continue; 168 + } 169 + 170 + const url = cdnBase + pkg.file_name; 171 + try { 172 + await downloadBinary(url, dest); 173 + console.log(chalk.green('downloaded')); 174 + } catch (err) { 175 + console.log(chalk.red(`FAILED — ${err.message}`)); 176 + if (fs.existsSync(dest)) fs.unlinkSync(dest); 177 + } 178 + } 179 + } 180 + 181 + // --------------------------------------------------------------------------- 182 + // Part 2 — Pure-Python packages from PyPI 88 183 // --------------------------------------------------------------------------- 89 184 90 185 /** 91 - * Returns the best pure-Python wheel for the latest release of `packageName`. 186 + * Queries the PyPI JSON API for `packageName` and returns the best 187 + * pure-Python wheel for the latest release. 92 188 * Preference: py3-none-any > py2.py3-none-any > *-none-any 93 189 */ 94 190 async function resolvePureWheel(packageName) { 95 - const raw = await httpsGet(`https://pypi.org/pypi/${packageName}/json`); 191 + const raw = await httpsGetText(`https://pypi.org/pypi/${packageName}/json`); 96 192 const data = JSON.parse(raw); 97 193 const version = data.info.version; 98 - const urls = data.urls; // files for the latest release 99 - 100 - const wheels = urls.filter((f) => f.filename.endsWith('.whl')); 194 + const wheels = data.urls.filter((f) => f.filename.endsWith('.whl')); 101 195 102 196 const ranked = [ 103 197 wheels.find((f) => f.filename.endsWith('-py3-none-any.whl')), ··· 107 201 108 202 if (ranked.length === 0) { 109 203 throw new Error( 110 - `No pure-Python wheel found for ${packageName} ${version}. ` + 111 - `Binary packages must come from the Pyodide npm bundle.` 204 + `No pure-Python wheel found for ${packageName} ${version}. ` + 205 + `Binary packages must come from the Pyodide CDN.` 112 206 ); 113 207 } 114 208 115 209 return { version, wheel: ranked[0] }; 116 210 } 117 211 118 - // --------------------------------------------------------------------------- 119 - // Main 120 - // --------------------------------------------------------------------------- 121 - 122 - async function installPackage(packageName, manifest) { 212 + async function installPyPIPackage(packageName, manifest) { 123 213 process.stdout.write(chalk.blue(` ${packageName}: `)); 124 214 125 215 let version, wheel; ··· 131 221 } 132 222 133 223 const dest = path.join(PACKAGES_DIR, wheel.filename); 134 - 135 224 if (fs.existsSync(dest)) { 136 225 console.log(chalk.gray(`${version} already present, skipping`)); 137 226 manifest[packageName] = { version, filename: wheel.filename }; ··· 148 237 } 149 238 } 150 239 151 - async function main() { 240 + async function downloadPyPIPackages() { 152 241 fs.mkdirSync(PACKAGES_DIR, { recursive: true }); 153 242 154 - // Preserve any previously downloaded packages in the manifest. 243 + // Preserve previously downloaded packages already recorded in the manifest. 155 244 let manifest = {}; 156 245 if (fs.existsSync(MANIFEST_FILE)) { 157 - try { 158 - manifest = JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8')); 159 - } catch { 160 - manifest = {}; 161 - } 246 + try { manifest = JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8')); } 247 + catch { manifest = {}; } 162 248 } 163 249 164 - console.log(chalk.blue.bold('Downloading MNE-Python wheels from PyPI…')); 165 - for (const pkg of PACKAGES_TO_DOWNLOAD) { 166 - await installPackage(pkg, manifest); 250 + console.log(chalk.blue.bold('\nDownloading MNE-Python wheels from PyPI…')); 251 + for (const pkg of PYPI_PACKAGES) { 252 + await installPyPIPackage(pkg, manifest); 167 253 } 168 254 169 255 fs.writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2)); 170 - console.log(chalk.green.bold('\nAll MNE wheels ready.')); 171 - console.log(chalk.gray(`Manifest → ${MANIFEST_FILE}`)); 256 + console.log(chalk.gray(` Manifest → ${MANIFEST_FILE}`)); 257 + } 258 + 259 + // --------------------------------------------------------------------------- 260 + // Entry point 261 + // --------------------------------------------------------------------------- 262 + 263 + async function main() { 264 + await downloadPyodidePackages(); 265 + await downloadPyPIPackages(); 266 + console.log(chalk.green.bold('\nAll packages ready.')); 172 267 } 173 268 174 269 main().catch((err) => {
+71 -70
internals/scripts/InstallPyodide.mjs
··· 1 1 #!/usr/bin/env node 2 2 /** 3 - * Downloads the Pyodide core tarball from GitHub releases and extracts it 4 - * into the renderer's public directory so Vite serves the runtime as a static 5 - * asset at /pyodide/… (Vite publicDir → src/renderer/utils/pyodide/src/). 3 + * Copies the Pyodide runtime from the installed npm package into the renderer's 4 + * publicDir so Vite can serve it as static assets. 6 5 * 7 - * The "core" tarball is ~40 MB and includes the runtime plus the most 8 - * commonly used scientific packages (numpy, scipy, matplotlib, pandas, …). 9 - * It is much smaller than the full Pyodide build (~500 MB). 6 + * Source: node_modules/pyodide/ 7 + * Dest: src/renderer/utils/pyodide/src/pyodide/ 8 + * 9 + * Key files copied: 10 + * pyodide.mjs – ESM entry point (imported by the web worker via npm) 11 + * pyodide.js – UMD fallback 12 + * pyodide.asm.js – compiled Python interpreter 13 + * pyodide.asm.wasm – WebAssembly binary 14 + * python_stdlib.zip – Python standard library 15 + * pyodide-lock.json – package registry (read by InstallMNE.mjs) 16 + * 17 + * Intentionally skipped: 18 + * package.json – would make Vite treat the dir as an npm package 19 + * and attempt to transform pyodide.mjs as a module 20 + * *.d.ts – TypeScript declaration files, not needed at runtime 21 + * *.html – console demo pages 22 + * README.md – documentation 23 + * *.map – source maps (large, optional for debugging) 24 + * 25 + * A version stamp (.pyodide-version) is written so subsequent runs are skipped 26 + * when the installed version has not changed. 10 27 * 11 28 * Usage: node internals/scripts/InstallPyodide.mjs 12 29 * Runs automatically via the postinstall npm hook. 13 30 */ 14 31 15 32 import fs from 'fs'; 16 - import https from 'https'; 17 33 import path from 'path'; 18 - import { pipeline } from 'stream/promises'; 34 + import { createRequire } from 'module'; 19 35 import chalk from 'chalk'; 20 - import bz2 from 'unbzip2-stream'; 21 - import tar from 'tar-fs'; 22 36 23 - const PYODIDE_VERSION = '0.29.3'; 24 - const TARBALL_NAME = `pyodide-core-${PYODIDE_VERSION}.tar.bz2`; 25 - const TARBALL_URL = `https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/${TARBALL_NAME}`; 37 + const _require = createRequire(import.meta.url); 26 38 27 - // Vite publicDir root — everything here is served verbatim by the dev server. 28 - const PUBLIC_ROOT = path.resolve('src/renderer/utils/pyodide/src'); 29 - // The tarball extracts into a `pyodide/` subdirectory, which ends up at: 30 - // src/renderer/utils/pyodide/src/pyodide/ → served at /pyodide/ 31 - const DEST_DIR = path.join(PUBLIC_ROOT, 'pyodide'); 39 + const DEST_DIR = path.resolve('src/renderer/utils/pyodide/src/pyodide'); 32 40 const VERSION_FILE = path.join(DEST_DIR, '.pyodide-version'); 33 41 34 - // --------------------------------------------------------------------------- 35 - // Network helpers (follow redirects) 36 - // --------------------------------------------------------------------------- 42 + // Files to exclude from the copy. 43 + const SKIP_EXTENSIONS = new Set(['.d.ts', '.map', '.html', '.md']); 44 + const SKIP_FILES = new Set(['package.json', 'README.md']); 37 45 38 - function httpsGetResponse(url) { 39 - return new Promise((resolve, reject) => { 40 - https 41 - .get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 42 - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 43 - resolve(httpsGetResponse(res.headers.location)); 44 - } else { 45 - resolve(res); 46 - } 47 - }) 48 - .on('error', reject); 49 - }); 46 + function shouldSkip(filename) { 47 + if (SKIP_FILES.has(filename)) return true; 48 + for (const ext of SKIP_EXTENSIONS) { 49 + if (filename.endsWith(ext)) return true; 50 + } 51 + return false; 50 52 } 51 53 52 - // --------------------------------------------------------------------------- 53 - // Main 54 - // --------------------------------------------------------------------------- 54 + async function main() { 55 + // Locate the pyodide package directory via Node's module resolution. 56 + let pyodideDir; 57 + try { 58 + pyodideDir = path.dirname(_require.resolve('pyodide/package.json')); 59 + } catch { 60 + console.error( 61 + chalk.red( 62 + 'pyodide not found in node_modules. Run `npm install` first.' 63 + ) 64 + ); 65 + process.exit(1); 66 + } 55 67 56 - async function main() { 57 - // Skip if this exact version is already extracted. 68 + const version = JSON.parse( 69 + fs.readFileSync(path.join(pyodideDir, 'package.json'), 'utf8') 70 + ).version; 71 + 72 + // Skip if this version was already installed. 58 73 if ( 59 74 fs.existsSync(VERSION_FILE) && 60 - fs.readFileSync(VERSION_FILE, 'utf8').trim() === PYODIDE_VERSION 75 + fs.readFileSync(VERSION_FILE, 'utf8').trim() === version 61 76 ) { 62 - console.log( 63 - chalk.green.bold(`Pyodide ${PYODIDE_VERSION} already installed, skipping.`) 64 - ); 77 + console.log(chalk.gray(`Pyodide ${version} already installed — skipping.`)); 65 78 return; 66 79 } 67 80 68 - fs.mkdirSync(PUBLIC_ROOT, { recursive: true }); 81 + console.log( 82 + chalk.blue.bold(`Installing Pyodide ${version} from node_modules…`) 83 + ); 84 + fs.mkdirSync(DEST_DIR, { recursive: true }); 69 85 70 - const tarballPath = path.join(PUBLIC_ROOT, TARBALL_NAME); 86 + const files = fs.readdirSync(pyodideDir); 87 + for (const file of files) { 88 + if (shouldSkip(file)) continue; 71 89 72 - // Download the tarball if not already cached. 73 - if (!fs.existsSync(tarballPath)) { 74 - console.log( 75 - chalk.blue.bold(`Downloading Pyodide ${PYODIDE_VERSION} core tarball…`) 76 - ); 77 - const res = await httpsGetResponse(TARBALL_URL); 78 - if (res.statusCode !== 200) { 79 - throw new Error(`Failed to download tarball: HTTP ${res.statusCode}`); 80 - } 81 - await pipeline(res, fs.createWriteStream(tarballPath)); 82 - console.log(chalk.gray(` Saved → ${tarballPath}`)); 83 - } else { 84 - console.log(chalk.gray(` Tarball already cached, skipping download.`)); 85 - } 90 + const src = path.join(pyodideDir, file); 91 + const dest = path.join(DEST_DIR, file); 86 92 87 - // Extract the tarball. The archive contains a top-level `pyodide/` 88 - // directory, so extracting into PUBLIC_ROOT gives us PUBLIC_ROOT/pyodide/. 89 - console.log(chalk.blue.bold(`Extracting…`)); 90 - await pipeline( 91 - fs.createReadStream(tarballPath), 92 - bz2(), 93 - tar.extract(PUBLIC_ROOT) 94 - ); 93 + if (fs.statSync(src).isDirectory()) continue; 95 94 96 - // Stamp the installed version and clean up the cached tarball. 97 - fs.writeFileSync(VERSION_FILE, PYODIDE_VERSION); 98 - fs.unlinkSync(tarballPath); 95 + process.stdout.write(chalk.blue(` ${file}: `)); 96 + fs.copyFileSync(src, dest); 97 + console.log(chalk.green('copied')); 98 + } 99 99 100 + fs.writeFileSync(VERSION_FILE, version); 100 101 console.log( 101 - chalk.green.bold(`Pyodide ${PYODIDE_VERSION} installed successfully.`) 102 + chalk.green.bold(`\nPyodide ${version} ready at ${DEST_DIR}`) 102 103 ); 103 104 } 104 105
+2
package.json
··· 16 16 "package-linux": "npm run build && electron-builder build --linux", 17 17 "package-win": "npm run build && electron-builder build --win --x64", 18 18 "postinstall": "electron-builder install-app-deps && node internals/scripts/InstallPyodide.mjs && node internals/scripts/InstallMNE.mjs && node internals/scripts/patchDeps.mjs", 19 + "install-pyodide": "node internals/scripts/InstallPyodide.mjs && node internals/scripts/InstallMNE.mjs", 19 20 "lint": "cross-env NODE_ENV=development eslint . --cache", 20 21 "lint-fix": "npm run lint -- --fix", 21 22 "lint-styles": "stylelint '**/*.*(css|scss)'", ··· 208 209 "papaparse": "^5.5.3", 209 210 "pathe": "^2.0.3", 210 211 "plotly.js": "^3.4.0", 212 + "pyodide": "^0.29.3", 211 213 "rc-slider": "9.2.4", 212 214 "react": "^18.x", 213 215 "react-dom": "^18.x",
+2 -2
src/renderer/epics/pyodideEpics.ts
··· 76 76 ); 77 77 78 78 // Once pyodide webworker is created, 79 - // Create an observable of events that corresond to what it retjurns 80 - // and then emite those events as redux actions 79 + // Create an observable of events that corresond to what it returns 80 + // and then emits those events as redux actions 81 81 const pyodideMessageEpic: Epic< 82 82 PyodideActionType, 83 83 PyodideActionType,
+6 -3
src/renderer/utils/pyodide/index.ts
··· 12 12 // Imports and Utility functions 13 13 14 14 export const loadPyodide = async () => { 15 - // Classic worker (importScripts used inside cannot run in module workers) 16 - const freshWorker = new Worker(new URL('./webworker.js', import.meta.url)); 15 + // Module worker — required for Pyodide 0.26+ which ships pyodide.mjs as ESM. 16 + const freshWorker = new Worker(new URL('./webworker.js', import.meta.url), { 17 + type: 'module', 18 + }); 17 19 return freshWorker; 18 20 }; 19 21 ··· 126 128 return; 127 129 } 128 130 return worker.postMessage({ 129 - data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`, 131 + // data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`, 132 + data: `[1,2,3,4]` 130 133 }); 131 134 }; 132 135
+51 -40
src/renderer/utils/pyodide/webworker.js
··· 1 1 /** 2 - * Pyodide Web Worker 2 + * Pyodide Web Worker — ES module worker following the pattern from 3 + * https://gitlab.com/castedo/pyodide-worker-example 4 + * 5 + * Loading strategy 6 + * ---------------- 7 + * 1. `import { loadPyodide } from "pyodide"` — Vite resolves this to 8 + * node_modules/pyodide/pyodide.mjs and serves it from /@fs/… in dev mode, 9 + * completely bypassing any publicDir transform issues. 10 + * 11 + * 2. `indexURL: '/pyodide/'` — tells pyodide where to find pyodide-lock.json 12 + * and binary package wheels (.whl). These are served from publicDir: 13 + * src/renderer/utils/pyodide/src/pyodide/ 14 + * which is populated by: 15 + * • InstallPyodide.mjs (copies pyodide-lock.json + runtime from npm) 16 + * • InstallMNE.mjs (downloads binary wheels from Pyodide CDN) 3 17 * 4 - * Load order: 5 - * 1. Pyodide runtime (served as a static asset at /pyodide/). 6 - * 2. Binary packages bundled with Pyodide (numpy, scipy, matplotlib, pandas). 7 - * 3. MNE-Python and its pure-Python deps, installed offline from local 8 - * wheel files that were pre-downloaded by `npm run install-mne-wheels`. 9 - * The manifest at /packages/manifest.json maps package names → filenames. 18 + * 3. Binary packages (numpy / scipy / matplotlib / pandas) — loaded via 19 + * pyodide.loadPackage(), resolved from local /pyodide/ files. 20 + * 21 + * 4. MNE + pure-Python deps — installed via micropip from pre-downloaded 22 + * wheels in /packages/, listed in /packages/manifest.json. 23 + * Populated by InstallMNE.mjs Part 2 (PyPI). 10 24 */ 11 25 12 - // Pyodide is served as a static asset at /pyodide/ (via Vite publicDir). 13 - // An absolute path is required so importScripts resolves correctly regardless 14 - // of where the worker script itself is served from. 15 - importScripts('/pyodide/pyodide.js'); 26 + import { loadPyodide } from 'pyodide'; 16 27 17 - async function loadPyodideAndPackages() { 18 - self.pyodide = await loadPyodide({ indexURL: '/pyodide/' }); 28 + async function initPyodide() { 29 + // indexURL tells pyodide where to load pyodide-lock.json and binary wheels. 30 + // The publicDir (src/renderer/utils/pyodide/src/) is served at the web root, 31 + // so /pyodide/ maps to src/renderer/utils/pyodide/src/pyodide/. 32 + const pyodide = await loadPyodide({ indexURL: '/pyodide/' }); 19 33 20 - // Load binary packages that are bundled with the Pyodide npm package and 21 - // therefore available locally without any network request. 22 - await self.pyodide.loadPackage(['numpy', 'scipy', 'matplotlib', 'pandas']); 34 + // Load binary packages from locally served .whl files. 35 + await pyodide.loadPackage(['numpy', 'scipy', 'matplotlib', 'pandas']); 23 36 24 - // Load MNE and its pure-Python dependencies from pre-downloaded wheel files. 25 - // The manifest was written by `node internals/scripts/InstallMNE.mjs`. 37 + // Install MNE and its pure-Python deps from pre-downloaded wheels. 26 38 let manifest = {}; 27 39 try { 28 - const response = await fetch('/packages/manifest.json'); 29 - if (response.ok) { 30 - manifest = await response.json(); 40 + const res = await fetch(new URL('/packages/manifest.json', self.location.href).href); 41 + if (res.ok) { 42 + manifest = await res.json(); 31 43 } else { 32 - console.warn('[pyodide worker] manifest.json not found — MNE will not be available'); 44 + console.warn('[pyodide worker] manifest.json not found — MNE unavailable'); 33 45 } 34 46 } catch (err) { 35 47 console.warn('[pyodide worker] Could not fetch manifest.json:', err); 36 48 } 37 49 38 - const wheelUrls = Object.values(manifest) 39 - .map((entry) => `/packages/${entry.filename}`); 50 + const wheelUrls = Object.values(manifest).map( 51 + (entry) => new URL(`/packages/${entry.filename}`, self.location.href).href 52 + ); 40 53 41 54 if (wheelUrls.length > 0) { 42 - await self.pyodide.loadPackage('micropip'); 43 - const micropip = self.pyodide.pyimport('micropip'); 44 - // micropip resolves relative URLs against the worker's base URL. 45 - // Pass absolute URLs so it works regardless of worker location. 46 - const absoluteUrls = wheelUrls.map( 47 - (u) => new URL(u, self.location.origin).href 48 - ); 49 - await micropip.install(absoluteUrls); 55 + await pyodide.loadPackage('micropip'); 56 + const micropip = pyodide.pyimport('micropip'); 57 + await micropip.install(wheelUrls); 50 58 } else { 51 - console.warn('[pyodide worker] No MNE wheels found in manifest.'); 59 + console.warn('[pyodide worker] No MNE wheels in manifest — skipping micropip install'); 52 60 } 61 + 62 + return pyodide; 53 63 } 54 64 55 - let pyodideReadyPromise = loadPyodideAndPackages(); 65 + // Start loading immediately so it is ready when the first message arrives. 66 + const pyodideReadyPromise = initPyodide(); 56 67 57 68 self.onmessage = async (event) => { 58 - await pyodideReadyPromise; 69 + const pyodide = await pyodideReadyPromise; 59 70 60 71 const { data, ...context } = event.data; 61 - for (const key of Object.keys(context)) { 62 - self[key] = context[key]; 72 + 73 + // Expose context values as globals so Python can access them via the js module. 74 + for (const [key, value] of Object.entries(context)) { 75 + self[key] = value; 63 76 } 64 77 65 78 try { 66 - self.postMessage({ 67 - results: await self.pyodide.runPythonAsync(data), 68 - }); 79 + self.postMessage({ results: await pyodide.runPythonAsync(data) }); 69 80 } catch (error) { 70 81 self.postMessage({ error: error.message }); 71 82 }
+9 -4
vite.config.ts
··· 44 44 // ------------------------------------------------------------------ 45 45 renderer: { 46 46 // Serve the pyodide runtime files as static assets so Vite does NOT 47 - // transform them. importScripts() in a classic worker cannot load 48 - // ES modules; Vite's HMR injection turns .js files into ESM, breaking 49 - // the worker. Files in publicDir are served verbatim at the root URL: 50 - // /pyodide/pyodide.js, /pyodide/pyodide.asm.js, etc. 47 + // transform them. Files in publicDir are served verbatim at the root URL: 48 + // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc. 51 49 publicDir: path.resolve(__dirname, 'src/renderer/utils/pyodide/src'), 52 50 plugins: [ 53 51 react({ ··· 77 75 }, 78 76 optimizeDeps: { 79 77 include: ['@neurosity/pipes'], 78 + // Prevent Vite from pre-bundling pyodide. In dev mode it will be served 79 + // raw from node_modules via /@fs/, which is what pyodide.mjs expects. 80 + exclude: ['pyodide'], 81 + }, 82 + worker: { 83 + // ES module workers are required for `import { loadPyodide } from "pyodide"`. 84 + format: 'es', 80 85 }, 81 86 build: { 82 87 rollupOptions: {