How to set up Vite and Electron from scratch, with any frontend framework
Luca
Posted on April 17, 2022
Do you like Vite and want to create an Electron project using it?
Do you also want to learn how to set up everything by yourself from scratch?
Good news, everyone! I got you covered, in this article I will explain in details, how to get started using Electron in Vite from scratch, with no boilerplate, no plugins (in fact you will create one on your own following this article) and using just tools that you may have in your toolbox.
TLDR;
Actually read I promise you it will be worth it, do not scroll to the bottom where there is a link to GitHub with the solution ready to go...
If you want to use Vue, React, Svelte or any other frontend framework, don't worry, the process is the same.
Less abstraction and more power to you so lets'go!
In this article I am gonna use
pnpm
, feel free to use your preferred package manager, Electron Builder recommend using yarn
Scaffolding Vite
Following the official documentation for Scaffolding Your First Vite Project, let's create a new vue-ts
project by running:
pnpm create vite electron-vite -- --template vue-ts
And remember you can use any template you want, the process is practically the same.
Cd into your project folder or just open VSCode:
cd electron-vite
And remember to install the dependencies:
pnpm i
If you are not a fan of TypeScript, it is used very sparingly in this article, and you can totally avoid it, just remember to replace all
.ts
with.js
.
Scaffolding Electron
We are now ready to set up Electron.
Create a file src-electron/main.ts
, it will be our electron entry point:
// src-electron/main.ts
import { app, BrowserWindow } from 'electron'
let mainWindow: BrowserWindow | undefined
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
useContentSize: true
})
mainWindow.loadURL('http://localhost:3000')
mainWindow.webContents.openDevTools()
mainWindow.on('closed', () => {
mainWindow = null
})
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow == null) {
createWindow()
}
})
Do not worry about the hard-coded location for now, I will show you how to make everything dynamic later on, and also, do not worry too much about setting up Electron perfectly so early on, let's build our way up first.
We are forgetting something, let's install electron as a dev dependency locally:
pnpm i electron -D
Combining Vite and Electron
Create a new Vite config named vite.config.electron.ts
it will help us to understand what we are doing while serving practical purposes later on:
// vite.config.electron.ts
import { defineConfig } from 'vite'
export default defineConfig({
publicDir: false,
build: {
ssr: 'src-electron/main.ts'
}
})
When building in Electron, we do not need the public directory, so we turn it off with publicDir: false
.
We also need to tell Vite to build an ssr target, Electron runs on Node and not on the browser, which is what Vite targets by default, build.ssr: 'src-electron/main.ts'
tells Vite just that.
Add a new script in your package.json (just make a new one, do not delete the ones already present):
"scripts": {
"build:electron": "vite build -c vite.config.electron.ts",
},
This will tell Vite to build using the config we have just created, as stated in the official Vite documentation Config File Resolving.
And let's run the script to see what we got:
pnpm build:electron
If everything went the right way, you should see something like this in your terminal:
Material Theme and Terminal Zoom VSCode extension makes such beautiful high quality images
To run electron, let's add another script to your package.json:
"scripts": {
"dev:electron": "electron dist/main.js",
"build:electron": "vite build -c vite.config.electron.ts",
},
The new script dev:electron
will run electron using our freshly build main.js
.
Make sure you run pnpm build:electron
prior, otherwise Electron will fail to start.
Dev server with Electron
Finally, let's bring everything together, we still have more to do but for now, let's enjoy Vite and Electron working together.
Start Vite dev server with:
pnpm vite
And lets take a closer look at the output:
Do you notice something? Yes that's the address we have used to Scaffolding Electron, at the start of this article, http://localhost:3000
is in fact the address of the Vite dev server.
So let's take a break from the code to really understand what we are doing here.
Electron acts very similarly to a regular web browser, so what we have done until now is really just setting up a toolchain that allow us to build and start electron pointing at our Vite dev server address.
Now you have probably guessed it, start electron in to a separate terminal with:
pnpm build:electron
pnpm dev:electron
And that's it, we now have a fully functional Electron application with Vite:
Recap Until this point
We went through tons of stuff so lets make a quick recap before proceeding further:
pnpm create vite electron-vite -- --template vue-ts
- Create a
src-electron/main.ts
file
// src-electron/main.ts
import { app, BrowserWindow } from 'electron'
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600
})
// poin to vite dev server port
mainWindow.loadURL('http://localhost:3000')
mainWindow.webContents.openDevTools()
}
app.whenReady().then(createWindow)
- Create a new Vite config
vite.config.electron.ts
// vite.config.electron.ts
import { defineConfig } from 'vite'
export default defineConfig({
publicDir: false, // dont publish the public dir
build: {
// electron run on node, not on the browser
ssr: 'src-electron/main.ts'
}
})
- Add new package.json scripts
"scripts": {
"dev:electron": "electron dist/main.js",
"build:electron": "vite build -c vite.config.electron.ts",
}
- Install electron and build
pnpm i electron
pnpm build:electron
- Run the project
pnpm vite & pnpm dev:electron
Or use two separate terminal and run pnpm vite
and pnpm dev:electron
Make everything automatic with a Vite plugin written from scratch
As a right now, we have some annoyance we want to get rid of, first of all, it is really boring setting everything up manually, and also if we make a change to the electron main file, sadly nothing reloads automatically.
Let's fix most of our problems leveraging Vite and Rollup.
Edit vite.config.ts
, do not edit the electron config, make sure you are editing the default Vite config:
// vite.config.ts
import { resolve } from 'path'
import { spawn, type ChildProcess } from 'child_process'
import type { ViteDevServer } from 'vite'
import { defineConfig, build } from 'vite'
import vue from '@vitejs/plugin-vue'
async function bundle(server: ViteDevServer) {
// this is RollupWatcher, but vite do not export its typing...
const watcher: any = await build({
// our config file, vite will not resolve this file
configFile: 'vite.config.electron.ts',
build: {
watch: {} // to make a watcher
}
})
// it returns a string pointing to the electron binary
const electron = require('electron') as string
// resolve the electron main file
const electronMain = resolve(
server.config.root,
server.config.build.outDir,
'main.js'
)
let child: ChildProcess | undefined
// exit the process when electron closes
function exitProcess() {
process.exit(0)
}
// restart the electron process
function start() {
if (child) {
child.kill()
}
child = spawn(electron, [electronMain], {
windowsHide: false
})
child.on('close', exitProcess)
}
function startElectron({ code }: any) {
if (code === 'END') {
watcher.off('event', startElectron)
start()
}
}
watcher.on('event', startElectron)
// watch the build, on change, restart the electron process
watcher.on('change', () => {
// make sure we dont kill our application when reloading
child.off('close', exitProcess)
start()
})
}
export default defineConfig({
plugins: [
vue(), // only if you are using vue
// this is a vite plugin, configureServer is vite-specific
{
name: 'electron-vite',
configureServer(server) {
server.httpServer.on('listening', () => {
bundle(server).catch(server.config.logger.error)
})
}
}
]
})
Run the Vite dev server and lets see some magic:
pnpm vite
You can also use
dev
if you have setup the project from a Vite template
Electron starts as soon Vite is ready to serve our application, not only that, but electron will automatically restart when we make a change to src-electron/main.ts
, pretty neat.
What we have created is simply a way to build and restart electron automatically, now we could delete our utility scripts as they are not needed anymore, however they are might still be useful for debugging and tinkering.
There is something more we need to do, Electron uses relative path to load files, but Vite uses the base option, which is /
by default, therefore our application will not work when we actually build the Vite project.
To fix this problem lest edit the Vite config:
// vite.config.ts
// ... the rest of the code, scroll down
export default defineConfig((env) => ({
// nice feature of vite as the mode can be set by the CLI
base: env.mode === 'production' ? './' : '/',
plugins: [
vue(), // only if you are using vue
{
name: 'electron-vite',
configureServer(server) {
server.httpServer.on('listening', () => {
bundle(server).catch(server.config.logger.error)
})
}
}
]
}))
This will make the base path relative, Electron will resolve your assets correctly, as an example, the following is how the index.html file should look like when you run vite build
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" href="./assets/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
As you can see, everything is prefixed with ./
.
Conclusions
Owww boys and girls this has been quite a fun, hopefully you now have a better understanding on Electron, Vite and how to go about making a plugin to tackle a problem.
Start from the ground up and build your way up, like a skyscraper!
And still, as I promise, we have not used a single plugin and instead, we have created one from scratch!
Ok wait a second, I have also promised to make the electron url fully dynamic.
And wait, what about building the project? And what about dev and production? And what about the preload scripts?
Have we still not have done yet?
Ok lets jump right back into the action.
We now have an automatic way to run electron, so lets improve upon that.
Starting by updating the electron main file:
import { app, BrowserWindow } from 'electron'
let mainWindow: BrowserWindow | undefined
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
useContentSize: true
})
// when in dev mode, load the url and open the dev tools
if (import.meta.env.DEV) {
mainWindow.loadURL(import.meta.env.ELECTRON_APP_URL)
mainWindow.webContents.openDevTools()
} else {
// in production, close the dev tools
mainWindow.webContents.on('devtools-opened', () => {
mainWindow.webContents.closeDevTools()
})
// load the build file instead
mainWindow.loadFile(import.meta.env.ELECTRON_APP_URL)
}
// ... the rest of the code
We are now using import.meta.env.DEV
which comes from Vite, but also a new env variable that does not exists import.meta.env.ELECTRON_APP_URL
.
Update your vite.config.ts
:
// vite.config.ts
import { type AddressInfo } from 'net'
import { resolve } from 'path'
import { spawn, type ChildProcess } from 'child_process'
import type { ViteDevServer } from 'vite'
import { defineConfig, build } from 'vite'
import vue from '@vitejs/plugin-vue'
async function bundle(server: ViteDevServer) {
// resolve the server address
const address = server.httpServer.address() as AddressInfo
const host =
address.address === '127.0.0.1'
? 'localhost'
: address.address
// build the url
const appUrl = `http://${host}:${address.port}`
// this is RollupWatcher, but vite do not export its typing...
const watcher: any = await build({
configFile: 'vite.config.electron.ts',
// mode is `development` when running vite
// mode is `production` when running vite build
mode: server.config.mode,
build: {
watch: {} // to make a watcher
},
define: {
// here we define a vite replacement
'import.meta.env.ELECTRON_APP_URL': JSON.stringify(appUrl)
}
})
// ... the rest of the code
Using the define config option in Vite, we can replace any string in our code with a specific value.
And remember that, the electron file is written on disk, it is located at dist/main.js
, so you can inspect the file, so let's do it right now:
// dist/main.js
"use strict";
var electron = require("electron");
let mainWindow;
function createWindow() {
mainWindow = new electron.BrowserWindow({
width: 800,
height: 600,
useContentSize: true
});
{
mainWindow.loadURL("http://localhost:3000");
mainWindow.webContents.openDevTools();
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
electron.app.whenReady().then(createWindow);
electron.app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
electron.app.quit();
}
});
electron.app.on("activate", () => {
if (mainWindow == null) {
createWindow();
}
});
That's the content of the file generated by Vite, it is a CJS module compatible with Node, and it is pretty much what you would have written by hand, but created by Vite!
Bonus tip, you can set
build.minify: true
to minify Electron main file.
Bundling and building Electron
For bundling Electron, we are going to use Electron Builder, so lets install it:
pnpm i electron-builder -D
For now, since we are not using any dependency for Electron, do not worry about .npmrc
, you can deal with it later.
Update your package.json
main
and build
like so:
"main": "dist/main.js",
"build": {
"files": [
"./dist/**/*"
]
},
This will instruct Electron Builder to use dist/main.js
and to also pack all the files under the dist
folder.
We now need a way to build Vite and Electron together, so lets hack something that works and then, we are going to clean everything up later.
Update the Vite config for electron:
// vite.config.electron.ts
import { defineConfig } from 'vite'
export default defineConfig({
publicDir: false,
build: {
// we build on top of vite, do not delete the generated files
emptyOutDir: false,
ssr: 'src-electron/main.ts'
},
define: {
// once again
'import.meta.env.ELECTRON_APP_URL': JSON.stringify('index.html')
}
})
Basically we tell Vite to not delete the out dir, dist
by default, since we are going to build Vite first, then Electron on the same folder.
So with everything ready to go let's build Electron.
Build Vite first:
pnpm vite build
Then build Electron, hopefully you haven't deleted the script we have created a couple of chapters ago:
pnpm build:electron
Or the equivalent command:
pnpm vite build -c vite.config.electron.ts
Check the dist
folder, it should contain your Vite application, alongside the Electron main.js
file:
dist
├── favicon.ico
├── index.html
├── main.js
├── assets
│ ├── .js, .css
Then build with electron-builder
, just like you normally would:
pnpm electron-builder
And that's it, you have build your first and surely not last Electron application using Vite!
Pat yourself on your back, we still need to cover some topics, so buckle up we are about to pick up some speed!
Preload Script
Context isolation is a big topic per se, you can read more on the official documentation at electron.js
In short, in Electron, the render thread, which is your frontend, do not have access to the Node API.
A preload script allows you to set up a communication between Node and your frontend, it is quite easy to setup, edit the src-electron/main.ts
file to use the preload script:
// src-electron/main.ts
import { resolve } from 'path'
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
...
webPreferences: {
contextIsolation: true,
// preload scripts must be an absolute path
preload: resolve(__dirname, 'preload.js')
}
})
Create the preload file src-electron/preload.ts
, it will run under Node so you can use any node package and function you want:
// src-electron/preload.ts
import fs from 'fs'
import { contextBridge } from 'electron'
contextBridge.exposeInMainWorld('readSettings', function () {
return JSON.parse(fs.readFileSync('./settings.json', 'utf8'))
})
ContextBridge basically allow us to do this:
// in your frontend
const settings = window.readSettings()
The Electron function exposeInMainWorld
, add a function to the window object named readSettings
in this case.
If you want typings, you need to create them manually:
// src/types.d.ts
interface Window {
readSettings: () => Record<string, any>
}
Or you can declare them locally:
declare global {
interface Window {
readSettings: () => Record<string, any>
}
}
You can get pretty advanced with typings, or they can get pretty messy, but for now, let's leave it that way.
Edit the Electron Vite config:
// vite.config.electron.ts
import { defineConfig } from 'vite'
export default defineConfig({
publicDir: false,
build: {
emptyOutDir: false,
ssr: true, // true means, use rollupOptions.input
rollupOptions: {
// the magic, we can build two separate files in one go!
input: ['src-electron/main.ts', 'src-electron/preload.ts']
}
},
define: {
'import.meta.env.ELECTRON_APP_URL': JSON.stringify('index.html')
}
})
And thats it, simply run:
pnpm dev
And we're done!
Troubleshooting
Be sure to use relative path for your assets files:
export default defineConfig((env) => ({
base: env.mode === 'production' ? './' : '/'
}))
Use an absolute path for the preload script:
webPreferences: {
preload: path.resolve(__dirname, './preload.js')
}
For fine tuning what Electron can or cannot load, you need to intercept the file:///
protocol, see the official Electron documentation for more.
Electron Builder do not resolve packages with pnpm see Note for PNPM at the official Electron Builder documentation.
Electron Builder bundle all the dependencies, so you should put everything that is not used in Electron into devDependencies.
Conclusions... Again
From this point onwards, you can start cleaning up the project if you intend to use it later on, as a starting template.
I encourage you to commit your work with git before moving forward, and start by removing vite.config.electron.ts
, infact, you have already created a plugin for your project that bundle Electron using almost the same configuration.
If you compare vite.config.electron.ts
and vite.config.ts
, they are doing almost the same thing, so you could actually embed one into the other.
Source on GitHub
The surce code from this article is available on github
https://github.com/lucacicada/vite-electron-from-scratch
There is a Plugin for it
If you are looking for a Vite plugin that does pretty much all of this and more, well guess what, I have created a plugin that does just that!
Head over github.com/armoniacore/armonia-vite
There is a starter template on GitHub
And a less bloated playground on GitHub
If you are still with me after this wall of text, your awesome!
Do not forget to share opinion, suggestions, critiques or just say hi in the comment section below!
Ciao!
Posted on April 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.