DEV Community

Luca
Luca

Posted on

How to set up Vite and Electron from scratch, with any frontend framework

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And remember to install the dependencies:

pnpm i
Enter fullscreen mode Exit fullscreen mode

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()
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
  }
})
Enter fullscreen mode Exit fullscreen mode

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",
  },
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If everything went the right way, you should see something like this in your terminal:

Electron Build Output
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",
  },
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And lets take a closer look at the output:

Vite Dev Server 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
Enter fullscreen mode Exit fullscreen mode

And that's it, we now have a fully functional Electron application with Vite:

Electron Vite Final Result

Recap Until this point

We went through tons of stuff so lets make a quick recap before proceeding further:

  1. Scaffold a Vite Project
pnpm create vite electron-vite -- --template vue-ts
Enter fullscreen mode Exit fullscreen mode
  1. 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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'
  }
})
Enter fullscreen mode Exit fullscreen mode
  1. Add new package.json scripts
  "scripts": {
    "dev:electron": "electron dist/main.js",
    "build:electron": "vite build -c vite.config.electron.ts",
  }
Enter fullscreen mode Exit fullscreen mode
  1. Install electron and build
pnpm i electron
Enter fullscreen mode Exit fullscreen mode
pnpm build:electron
Enter fullscreen mode Exit fullscreen mode
  1. Run the project
pnpm vite & pnpm dev:electron
Enter fullscreen mode Exit fullscreen mode

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)
        })
      }
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Run the Vite dev server and lets see some magic:

pnpm vite
Enter fullscreen mode Exit fullscreen mode

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)
        })
      }
    }
  ]
}))
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/**/*"
    ]
  },
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then build Electron, hopefully you haven't deleted the script we have created a couple of chapters ago:

pnpm build:electron
Enter fullscreen mode Exit fullscreen mode

Or the equivalent command:

pnpm vite build -c vite.config.electron.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then build with electron-builder, just like you normally would:

pnpm electron-builder
Enter fullscreen mode Exit fullscreen mode

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')
    }
  })
Enter fullscreen mode Exit fullscreen mode

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'))
})
Enter fullscreen mode Exit fullscreen mode

ContextBridge basically allow us to do this:

// in your frontend
const settings = window.readSettings()
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Or you can declare them locally:

declare global {
  interface Window {
    readSettings: () => Record<string, any>
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

And thats it, simply run:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

And we're done!

Troubleshooting

Be sure to use relative path for your assets files:

export default defineConfig((env) => ({
  base: env.mode === 'production' ? './' : '/'
}))
Enter fullscreen mode Exit fullscreen mode

Use an absolute path for the preload script:

webPreferences: {
  preload: path.resolve(__dirname, './preload.js')
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (1)

Collapse
 
artydev profile image
artydev

Thank you :-)