Skip to content

Development

Project Structure

Conventions

It is recommended to use the following project structure:

.
├──src
│  ├──main
│  │  ├──index.ts
│  │  └──...
│  ├──preload
│  │  ├──index.ts
│  │  └──...
│  └──renderer    # with vue, react, etc.
│     ├──src
│     ├──index.html
│     └──...
├──electron.vite.config.ts
├──package.json
└──...

With this convention, electron-vite can work with minimal configuration.

When running electron-vite, it will automatically find the main process, preload script and renderer entry ponits. The default entry points:

  • Main process: <root>/src/main/{index|main}.{js|ts|mjs|cjs}
  • Preload script: <root>/src/preload/{index|preload}.{js|ts|mjs|cjs}
  • Renderer: <root>/src/renderer/index.html

It will throw an error if the entry points cannot be found. You can fix it by setting the build.rollupOptions.input option.

See the example in the next section.

Customizing

Even though we strongly recommend the project structure above, it is not a requirement. You can configure it to meet your scenes.

Suppose you have the following project structure:

.
├──electron
│  ├──main
│  │  ├──index.ts
│  │  └──...
│  └──preload
│     ├──index.ts
│     └──...
├──src   # with vue, react, etc.
├──index.html
├──electron.vite.config.ts
├──package.json
└──...

Your electron.vite.config.ts should be:

import { defineConfig } from 'electron-vite'
import { resolve } from 'path'

export default defineConfig({
  main: {
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'electron/main/index.ts')
        }
      }
    }
  },
  preload: {
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'electron/preload/index.ts')
        }
      }
    }
  },
  renderer: {
    root: '.',
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'index.html')
        }
      }
    }
  }
})

NOTE

By default, the renderer's working directory is located in src/renderer. In this example, the renderer root option should be set to '.'.

Using Preload Scripts

Preload scripts are injected before a web page loads in the renderer. To add features to your renderer that require privileged access, you can define global objects through the contextBridge API.

The role of preload scripts:

  • Augmenting the renderer: preload scripts run in a context that has access to the HTML DOM APIs and a limited subset of Node.js and Electron APIs.
  • Communicating between main and renderer processes: use Electron's ipcMain and ipcRenderer modules for inter-process communication (IPC).

eproc

Learn more about preload scripts.

Example

  1. Create a preload script to expose functions and variables into renderer via contextBridge.exposeInMainWorld.
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electron', {
  ping: () => ipcRenderer.invoke('ping')
})
  1. To attach this script to your renderer process, pass its path to the webPreferences.preload option in the BrowserWindow constructor:






 



 








import { app, BrowserWindow } from 'electron'
import path from 'path'

const createWindow = () => {
  const win = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  })

  ipcMain.handle('ping', () => 'pong')

  win.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
})
  1. Use exposed functions and variables in the renderer process:

 





const func = async () => {
  const response = await window.electron.ping()
  console.log(response) // prints out 'pong'
}

func()

Limitations of Sandboxing

From Electron 20 onwards, preload scripts are sandboxed by default and no longer have access to a full Node.js environment. Practically, this means that you have a polyfilled require function (similar to Node's require module) that only has access to a limited set of APIs.

Available APIDetails
Electron modulesOnly Renderer Process Modules
Node.js modulesevents, timers, url
Polyfilled globalsBuffer, process, clearImmediate, setImmediate

NOTE

Because the require function is a polyfill with limited functionality, you will not be able to use CommonJS modules to separate your preload script into multiple files, unless sandbox: false is specified.

In Electron, renderer sandboxing can be disabled on a per-process basis with the sandbox: false preference in the BrowserWindow constructor.

const win = new BrowserWindow({
  webPreferences: {
    sandbox: false
  }
})

Learn more about Electron Process Sandboxing.

Efficient

Perhaps some developers think that using preload scripts is inconvenient and inflexible. But why we recommend:

  • It's safe practice, most popular Electron apps (slack, visual studio code, etc.) do this.
  • Avoid mixed development (nodejs and browser), make renderer as a regular web app and easier to get started for web developers.

Based on efficiency considerations, it is recommended to use @electron-toolkit/preload. It's very easy to expose Electron APIs (ipcRenderer, webFrame, process) into renderer.

First, use contextBridge to expose Electron APIs into renderer only if context isolation is enabled, otherwise just add to the DOM global.

import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'

if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('electron', electronAPI)
  } catch (error) {
    console.error(error)
  }
} else {
  window.electron = electronAPI
}

Then, use the Electron APIs directly in the renderer process:

// Send a message to the main process with no response
window.electron.ipcRenderer.send('electron:say', 'hello')

// Send a message to the main process with the response asynchronously
window.electron.ipcRenderer.invoke('electron:doAThing', '').then(re => {
  console.log(re)
})

// Receive messages from the main process
window.electron.ipcRenderer.on('electron:reply', (_, args) => {
  console.log(args)
})

Learn more about @electron-toolkit/preload.

NOTE

@electron-toolkit/preload need to disable the sandbox.

IPC SECURITY

The safest way is to use a helper function to wrap the ipcRenderer call rather than expose the ipcRenderer module directly via context bridge.

Webview

The easiest way to attach a preload script to a webview is through the webContents will-attach-webview event handler.

mainWindow.webContents.on('will-attach-webview', (e, webPreferences) => {
  webPreferences.preload = join(__dirname, '../preload/index.js')
})

nodeIntegration

Currently, electorn-vite not support nodeIntegration. One of the important reasons is that vite's HMR is implemented based on native ESM. But there is also a way to support that is to use require to import the node module which is not very elegant. Or you can use plugin vite-plugin-commonjs-externals to handle.

Perhaps there's a better way to support this in the future. But It is important to note that using preload scripts is a better and safer option.

dependencies vs devDependencies

  • For the main process and preload scripts, the best practice is to externalize dependencies and only bundle our own code.

    We need to install the dependencies required by the app into the dependencies of package.json. Then use externalizeDepsPlugin to externalize them without bundle them.





     


     




    import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
    
    export default defineConfig({
      main: {
        plugins: [externalizeDepsPlugin()]
      },
      preload: {
        plugins: [externalizeDepsPlugin()]
      },
      // ...
    })
    

    When packaging the app, these dependencies will also be packaged together, such as electron-builder. Don't worry about them being lost. On the other hand, devDependencies will not be packaged.

    It is important to note that some modules that only support ESM (e.g. lowdb, execa, node-fetch), we should not externalize it. We should let electron-vite bundle it into a CJS standard module to support Electron.





     















    import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
    
    export default defineConfig({
      main: {
        plugins: [externalizeDepsPlugin({ exclude: ['lowdb'] })],
        build: {
          rollupOptions: {
            output: {
              manualChunks(id) {
                if (id.includes('lowdb')) {
                  return 'lowdb'
                }
              }
            }
          }
        }
      },
      // ...
    })
    
  • For renderers, it is usually fully bundle, so dependencies are best installed in devDependencies. This makes the final package more smaller.

Multiple Windows App

When your electron app has multiple windows, it means there are multiple html files or preload files. You can modify your config file like this:

// electron.vite.config.js
export default {
  main: {},
  preload: {
    build: {
      rollupOptions: {
        input: {
          browser: resolve(__dirname, 'src/preload/browser.js'),
          webview: resolve(__dirname, 'src/preload/webview.js')
        }
      }
    }
  },
  renderer: {
    build: {
      rollupOptions: {
        input: {
          browser: resolve(__dirname, 'src/renderer/browser.html'),
          webview: resolve(__dirname, 'src/renderer/webview.html')
        }
      }
    }
  }
}

How to Load Multi-Page

Check out the Using HMR section for more details.

Passing CLI Arguments to Electron App

It is recommended to handle command line via Env Variables and Modes:

  • For Electron CLI command:
import { app } from 'electron'
if (import.meta.env.MAIN_VITE_LOG === 'true') {
  app.commandLine.appendSwitch('enable-logging', 'electron_debug.log')
}

In development, you can use the above method to handle. After distribution, you can directly attach arguments supported by Electron. e.g. .\app.exe --enable-logging.

NOTE

electron-vite already supports inspect, inspect-brk and remote-debugging-port commands, so you don’t need to do this for those commands. See Command Line Interface for more details.

  • For app arguments:
const param = import.meta.env.MAIN_VITE_MY_PARAM === 'true' || /--myparam/.test(process.argv[2])
  1. In development, using import.meta.env and Modes to decide whether to use.
  2. In production (app), using process.argv to handle.

Released under the MIT License