···7676 );
77777878// Once pyodide webworker is created,
7979-// Create an observable of events that corresond to what it retjurns
8080-// and then emite those events as redux actions
7979+// Create an observable of events that corresond to what it returns
8080+// and then emits those events as redux actions
8181const pyodideMessageEpic: Epic<
8282 PyodideActionType,
8383 PyodideActionType,
+6-3
src/renderer/utils/pyodide/index.ts
···1212// Imports and Utility functions
13131414export const loadPyodide = async () => {
1515- // Classic worker (importScripts used inside cannot run in module workers)
1616- const freshWorker = new Worker(new URL('./webworker.js', import.meta.url));
1515+ // Module worker — required for Pyodide 0.26+ which ships pyodide.mjs as ESM.
1616+ const freshWorker = new Worker(new URL('./webworker.js', import.meta.url), {
1717+ type: 'module',
1818+ });
1719 return freshWorker;
1820};
1921···126128 return;
127129 }
128130 return worker.postMessage({
129129- data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`,
131131+ // data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`,
132132+ data: `[1,2,3,4]`
130133 });
131134};
132135
+51-40
src/renderer/utils/pyodide/webworker.js
···11/**
22- * Pyodide Web Worker
22+ * Pyodide Web Worker — ES module worker following the pattern from
33+ * https://gitlab.com/castedo/pyodide-worker-example
44+ *
55+ * Loading strategy
66+ * ----------------
77+ * 1. `import { loadPyodide } from "pyodide"` — Vite resolves this to
88+ * node_modules/pyodide/pyodide.mjs and serves it from /@fs/… in dev mode,
99+ * completely bypassing any publicDir transform issues.
1010+ *
1111+ * 2. `indexURL: '/pyodide/'` — tells pyodide where to find pyodide-lock.json
1212+ * and binary package wheels (.whl). These are served from publicDir:
1313+ * src/renderer/utils/pyodide/src/pyodide/
1414+ * which is populated by:
1515+ * • InstallPyodide.mjs (copies pyodide-lock.json + runtime from npm)
1616+ * • InstallMNE.mjs (downloads binary wheels from Pyodide CDN)
317 *
44- * Load order:
55- * 1. Pyodide runtime (served as a static asset at /pyodide/).
66- * 2. Binary packages bundled with Pyodide (numpy, scipy, matplotlib, pandas).
77- * 3. MNE-Python and its pure-Python deps, installed offline from local
88- * wheel files that were pre-downloaded by `npm run install-mne-wheels`.
99- * The manifest at /packages/manifest.json maps package names → filenames.
1818+ * 3. Binary packages (numpy / scipy / matplotlib / pandas) — loaded via
1919+ * pyodide.loadPackage(), resolved from local /pyodide/ files.
2020+ *
2121+ * 4. MNE + pure-Python deps — installed via micropip from pre-downloaded
2222+ * wheels in /packages/, listed in /packages/manifest.json.
2323+ * Populated by InstallMNE.mjs Part 2 (PyPI).
1024 */
11251212-// Pyodide is served as a static asset at /pyodide/ (via Vite publicDir).
1313-// An absolute path is required so importScripts resolves correctly regardless
1414-// of where the worker script itself is served from.
1515-importScripts('/pyodide/pyodide.js');
2626+import { loadPyodide } from 'pyodide';
16271717-async function loadPyodideAndPackages() {
1818- self.pyodide = await loadPyodide({ indexURL: '/pyodide/' });
2828+async function initPyodide() {
2929+ // indexURL tells pyodide where to load pyodide-lock.json and binary wheels.
3030+ // The publicDir (src/renderer/utils/pyodide/src/) is served at the web root,
3131+ // so /pyodide/ maps to src/renderer/utils/pyodide/src/pyodide/.
3232+ const pyodide = await loadPyodide({ indexURL: '/pyodide/' });
19332020- // Load binary packages that are bundled with the Pyodide npm package and
2121- // therefore available locally without any network request.
2222- await self.pyodide.loadPackage(['numpy', 'scipy', 'matplotlib', 'pandas']);
3434+ // Load binary packages from locally served .whl files.
3535+ await pyodide.loadPackage(['numpy', 'scipy', 'matplotlib', 'pandas']);
23362424- // Load MNE and its pure-Python dependencies from pre-downloaded wheel files.
2525- // The manifest was written by `node internals/scripts/InstallMNE.mjs`.
3737+ // Install MNE and its pure-Python deps from pre-downloaded wheels.
2638 let manifest = {};
2739 try {
2828- const response = await fetch('/packages/manifest.json');
2929- if (response.ok) {
3030- manifest = await response.json();
4040+ const res = await fetch(new URL('/packages/manifest.json', self.location.href).href);
4141+ if (res.ok) {
4242+ manifest = await res.json();
3143 } else {
3232- console.warn('[pyodide worker] manifest.json not found — MNE will not be available');
4444+ console.warn('[pyodide worker] manifest.json not found — MNE unavailable');
3345 }
3446 } catch (err) {
3547 console.warn('[pyodide worker] Could not fetch manifest.json:', err);
3648 }
37493838- const wheelUrls = Object.values(manifest)
3939- .map((entry) => `/packages/${entry.filename}`);
5050+ const wheelUrls = Object.values(manifest).map(
5151+ (entry) => new URL(`/packages/${entry.filename}`, self.location.href).href
5252+ );
40534154 if (wheelUrls.length > 0) {
4242- await self.pyodide.loadPackage('micropip');
4343- const micropip = self.pyodide.pyimport('micropip');
4444- // micropip resolves relative URLs against the worker's base URL.
4545- // Pass absolute URLs so it works regardless of worker location.
4646- const absoluteUrls = wheelUrls.map(
4747- (u) => new URL(u, self.location.origin).href
4848- );
4949- await micropip.install(absoluteUrls);
5555+ await pyodide.loadPackage('micropip');
5656+ const micropip = pyodide.pyimport('micropip');
5757+ await micropip.install(wheelUrls);
5058 } else {
5151- console.warn('[pyodide worker] No MNE wheels found in manifest.');
5959+ console.warn('[pyodide worker] No MNE wheels in manifest — skipping micropip install');
5260 }
6161+6262+ return pyodide;
5363}
54645555-let pyodideReadyPromise = loadPyodideAndPackages();
6565+// Start loading immediately so it is ready when the first message arrives.
6666+const pyodideReadyPromise = initPyodide();
56675768self.onmessage = async (event) => {
5858- await pyodideReadyPromise;
6969+ const pyodide = await pyodideReadyPromise;
59706071 const { data, ...context } = event.data;
6161- for (const key of Object.keys(context)) {
6262- self[key] = context[key];
7272+7373+ // Expose context values as globals so Python can access them via the js module.
7474+ for (const [key, value] of Object.entries(context)) {
7575+ self[key] = value;
6376 }
64776578 try {
6666- self.postMessage({
6767- results: await self.pyodide.runPythonAsync(data),
6868- });
7979+ self.postMessage({ results: await pyodide.runPythonAsync(data) });
6980 } catch (error) {
7081 self.postMessage({ error: error.message });
7182 }
+9-4
vite.config.ts
···4444 // ------------------------------------------------------------------
4545 renderer: {
4646 // Serve the pyodide runtime files as static assets so Vite does NOT
4747- // transform them. importScripts() in a classic worker cannot load
4848- // ES modules; Vite's HMR injection turns .js files into ESM, breaking
4949- // the worker. Files in publicDir are served verbatim at the root URL:
5050- // /pyodide/pyodide.js, /pyodide/pyodide.asm.js, etc.
4747+ // transform them. Files in publicDir are served verbatim at the root URL:
4848+ // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc.
5149 publicDir: path.resolve(__dirname, 'src/renderer/utils/pyodide/src'),
5250 plugins: [
5351 react({
···7775 },
7876 optimizeDeps: {
7977 include: ['@neurosity/pipes'],
7878+ // Prevent Vite from pre-bundling pyodide. In dev mode it will be served
7979+ // raw from node_modules via /@fs/, which is what pyodide.mjs expects.
8080+ exclude: ['pyodide'],
8181+ },
8282+ worker: {
8383+ // ES module workers are required for `import { loadPyodide } from "pyodide"`.
8484+ format: 'es',
8085 },
8186 build: {
8287 rollupOptions: {