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

BrainWaves Migration Session Summary#

Architecture Change: Webpack/Babel/Yarn → Electron-Vite/npm#

The project was migrated from a legacy Electron + Webpack + Babel + Yarn stack to a modern electron-vite setup. This is a significant architectural shift:

Concern Before After
Build system Webpack + Babel electron-vite (esbuild + Rollup)
Package manager Yarn npm
Module format CommonJS (require) ESM (import)
Env variables (renderer) process.env.* import.meta.env.VITE_*
Process split Single config Three explicit targets: main, preload, renderer
Path utilities path-browserify (2019, no ESM) pathe (modern, pure ESM)
Dev server Webpack HMR Vite HMR

The electron-vite architecture enforces a clean Electron process split:

  • Main (src/main/index.ts) — Node.js process, IPC handlers, file system
  • Preload (src/preload/index.ts) — sandboxed bridge with contextBridge
  • Renderer (src/renderer/) — pure browser context, React app

Major Work Done#

1. Build System Migration#

  • Replaced all Webpack config with vite.config.ts using defineConfig from electron-vite
  • Converted require() calls to ESM import statements across the codebase
  • Used git mv for all file renames to preserve git history
  • Set package.json "main" field to "./out/main/index.js" (electron-vite's output convention)

2. Electron API Modernization#

  • Replaced deprecated devtools APIs: session.getAllExtensions() / session.loadExtension()session.extensions.* (new namespaced API)
  • Fixed preload process conflict: Removed redundant import process from 'process' — Electron injects it natively
  • Added dev HTTP cache clearing: session.defaultSession.clearCache() in app.whenReady() (dev only) to prevent Electron's persistent HTTP cache from serving stale Vite pre-bundled assets

3. Dependency Upgrades#

Package From To Reason
@neurosity/pipes v3 v5 Eliminated dsp.js which used this[name] globals incompatible with strict ESM
rxjs v6 v7 Required by pipes v5
redux-observable v1 v2-rc Required by RxJS v7
plotly.js v1.54 (bundles d3 v3) v2.35 (uses d3 v6) Eliminated all this.document / this.navigator / this.Element errors
react-plotly.js v2.4 v2.6 Compatibility with plotly.js v2
d3 (direct) v5.16 v7.9 Modern pure-ESM version
path-browserify v1 (2019, CJS) pathe (modern ESM) Drop-in replacement with active maintenance

4. Environment Variable Migration#

Renderer code cannot access process.env in Vite (no Node.js context). All renderer references were migrated:

  • process.env.CLIENT_IDimport.meta.env.VITE_CLIENT_ID
  • process.env.NODE_ENVimport.meta.env.MODE
  • Emotiv SDK credentials are loaded from keys.js at config time and injected as process.env.VITE_* so Vite picks them up natively

5. Content Security Policy (CSP)#

Built up the CSP in src/renderer/index.html incrementally to allow legitimate sources while remaining secure:

  • Added https://fonts.googleapis.com to style-src (Semantic UI's Google Fonts)
  • Added https://fonts.gstatic.com to font-src (actual font files)
  • Added webpack: to connect-src (source map protocol)
  • Added 'self' to worker-src (Vite serves workers as HTTP URLs in dev, not blob:)

6. Pyodide / Web Worker Fix#

  • Problem: Vite transforms every .js file it serves by injecting import { createHotContext } from '/@vite/client', turning files into ES modules. importScripts() in a classic worker cannot execute ES modules — causing a NetworkError.
  • Fix: Configured publicDir in the renderer Vite config to point at the pyodide install directory (src/renderer/utils/pyodide/src/). Vite serves publicDir files verbatim with zero transformation. Updated webworker.js to use absolute paths (/pyodide/pyodide.js) instead of fragile relative ones.

7. redux-observable v2 API Fix#

action$.ofType() was removed in redux-observable v2. Updated three call sites in experimentEpics.ts to use the pipeable ofType operator:

// Before (v1):
action$.ofType('@@router/LOCATION_CHANGE').pipe(...)

// After (v2):
action$.pipe(ofType('@@router/LOCATION_CHANGE'), ...)

8. Browser Compatibility Fixes#

  • cortex.js: global.processtypeof process !== 'undefined' && process.env (no global in browser)
  • muse.ts: Removed import 'hazardous' — a Node.js-only asar path library that was incorrectly imported in the renderer

Key Roadblocks#

Electron HTTP Cache vs. Vite Pre-bundle Cache#

The trickiest issue of the session. Vite sets Cache-Control: max-age=31536000, immutable on pre-bundled deps. Electron's renderer stores these permanently in ~/Library/Application Support/BrainWaves/Cache/. Even after patching files on disk, Electron kept serving the old cached version because the URL's v= hash hadn't changed (Vite keys its cache hash on the package version, not file content). The solution required both patching the Vite pre-bundle cache file on disk and clearing the Electron session HTTP cache at startup in dev mode (session.defaultSession.clearCache()).

plotly.js / d3 v3 this.xxx Chain#

Three separate globals (this.document, this.Element, this.CSSStyleDeclaration, this.navigator) needed patching before the root cause was identified as d3 v3 being bundled inside plotly.js v1. In Vite's strict-mode ESM context, bare this at the module level is undefined. Upgrading to plotly.js v2 (which uses d3 v6, pure ESM) eliminated all of them at once.

patchDeps.mjs Strategy Evolution#

The plotly fix went through several iterations before the root cause was found:

  1. Vite server middleware to intercept HTTP requests — failed due to middleware ordering
  2. esbuild plugin in optimizeDeps.esbuildOptions — didn't apply to already-cached bundles
  3. Patching the npm source only — Vite doesn't re-bundle when the package version hasn't changed
  4. Patching both source and Vite's cached pre-bundle file — worked, but made entirely moot by upgrading plotly.js to v2

Pyodide Worker Loading#

The worker's importScripts() call appeared to reference a valid URL, but the load silently failed. The cause was subtle: Vite injects HMR boilerplate (an import statement) into every .js file it serves, converting them to ES modules. importScripts() in a classic worker can only execute classic scripts — not ES modules. Moving pyodide to publicDir bypassed Vite's transform pipeline entirely.