Create a secure Electron application with TypeScript and Vite.js (preload, renderer, main, IPC)

Thomas Cazade / March 22, 2022

Leverage the power of Vite.js to create a secure Electron application with TypeScript using any front-end framework (React, Vue 3, Svelte, ...)

Introduction

Vite.js is a frontend tool that gained a lot of popularity in the last year due to its low-setup integration with any popular frontend framework and its amazing speed, thanks to esbuild and ES Modules.

What is amazing with Vite, is that you can use it for really anything you want. You can create a dev-server whatever is your frontend framework (thanks to Vite plugins which provides a seamless integration), use it to bundle a component library (Vite uses Rollup under the hood for the lib compilation option), or even use it to create a simple static site.

In this post, we will create an Electron application with TypeScript, using Vite.js as the build-tool and Vue 3 for the frontend.

Prerequisites

Creating project using Vite.js CLI

Invoke the official Vite.js CLI:

$ npm create vite@latest ✔ Project name: … vue-3-electron-vite ✔ Select a framework: › Vue ✔ Select a variant: › TypeScript Scaffolding project in ~/projects/vue-3-electron-vite... Done. Now run: cd vue-3-electron-vite npm install npm run dev

Project is created, let's install the dependencies:

$ cd vue-3-electron-vite # Install project dependencies and add electron as a dev-dependency. $ npm install && npm install --save-dev electron

Project structure

We will follow the monorepo structure, however we will not integrate a package.json for each package as we only need a single package.json for all our packages.

Each package will have its own tsconfig.json and vite.config.ts.

Here is the project structure we will create:

vue-3-electron-vite/ ├─ node_modules/ ├─ scripts/ # Custom scripts, we will create one to run a dev-server with Electron. ├─ dist/ # Contain compiled output from each package. │ ├─ renderer/ # Compiled output of renderer process. │ ├─ main/ # Compiled output of main process. │ ├─ preload/ # Compiled output of preload process. ├─ packages/ │ ├─ renderer/ # Contains renderer process source-code. │ │ ├─ src/ │ │ ├─ vite.config.ts # Vite config for renderer source-code │ │ ├─ tsconfig.json # Specific TypeScript config. │ ├─ main/ # Contains main process source-code. │ │ ├─ src/ │ │ ├─ vite.config.ts # Vite config for main source-code. │ │ ├─ tsconfig.json # Specific TypeScript config. │ ├─ preload/ # Contains preload script source-code. │ │ ├─ src/ │ │ ├─ vite.config.ts # Vite config for preload source-code. │ │ ├─ tsconfig.json # Specific TypeScript config. ├─ package.json # Contains the dependencies for all our packages. ├─ index.html # Vite entry point for the renderer. ├─ tsconfig.node.json # Root file TypeScript config generated by Vite CLI.

Create the required folders to bootstrap the monorepo structure:

$ mkdir -p packages/renderer packages/main packages/preload

Update tsconfig.node.json to include our vite.config.ts files from each package and add ES2019 to the lib array:

// tsconfig.node.json { "compilerOptions": { // ... "lib": ["ES2019"] }, "include": [ "packages/renderer/vite.config.ts", "packages/main/vite.config.ts", "packages/preload/vite.config.ts" ] }

Renderer package

Let's move the generated folders and files from the Vite CLI into packages/renderer/:

$ mv src packages/renderer && mv public packages/renderer $ mv index.html packages/renderer && mv vite.config.ts packages/renderer && mv tsconfig.json packages/renderer

Update include and references relative paths in packages/renderer/tsconfig.json because we moved this file after it was generated:

// packages/renderer/tsconfig.json { // ... "include": ["./**/*.ts", "./**/*.d.ts", "./**/*.tsx", "./**/*.vue"], "references": [{ "path": "../../tsconfig.node.json" }] }

Edit packages/renderer/vite.config.ts to change the build output, relative path and add specific rollup options:

// vite.config.ts import { join } from 'node:path' import { builtinModules } from 'node:module' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], // Please note that `__dirname = packages/renderer` in this context. root: __dirname, base: './', build: { sourcemap: true, emptyOutDir: true, // Build output inside `dist/renderer` at the project root. outDir: '../../dist/renderer', rollupOptions: { // Entry point/input should be the `packages/renderer/index.html`. input: join(__dirname, 'index.html'), // Exclude node internal modules from the build output (we're building for web, not Node). external: [...builtinModules.flatMap((p) => [p, `node:${p}`])], }, }, })

Define scripts in the package.json to run a dev-server and build the renderer package:

// package.json { // ... "scripts": { "dev:renderer": "vite packages/renderer", "build:renderer": "vue-tsc -p packages/renderer/tsconfig.json --noEmit && vite build packages/renderer" } }

At this point, running npm run dev:renderer will create a dev-server and npm run build:renderer will create a production build of the renderer package.

A little explanation about what is going on with build:renderer:

  • vue-tsc -p packages/renderer/tsconfig.json --noEmit: vue-tsc is the TypeScript compiler adapted for Vue 3 to compile .vue files. -p packages/renderer/tsconfig.json is the path to the tsconfig.json file to use for the compiler. --noEmit is used to only check for errors and not emit any files. In other words, this command is used to check for TypeScript errors in the renderer project.
  • vite build packages/renderer: vite build is the build command. Vite will load the packages/renderer/vite.config.ts config file with the option packages/renderer.

Preload package

Initialize the following files inside packages/preload:

$ touch packages/preload/tsconfig.json
// packages/preload/tsconfig.json { "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "node", "strict": true, "sourceMap": true, "skipLibCheck": true, "isolatedModules": true, "lib": ["esnext", "dom"] }, "include": ["./**/*.ts"], "references": [{ "path": "../../tsconfig.node.json" }] }
$ touch packages/preload/vite.config.ts
// packages/preload/vite.config.ts import { builtinModules } from 'node:module' import { defineConfig } from 'vite' export default defineConfig({ // Please note that `__dirname = packages/preload` in this context. root: __dirname, // The directory from which `.env` files are loaded. // Make sure it should be at the root of the project. envDir: process.cwd(), build: { // Add inline sourcemap sourcemap: 'inline', // Build output inside `dist/preload` at the project root. outDir: '../../dist/preload', emptyOutDir: true, // Build preload in "lib" mode of Vite. // See: https://vitejs.dev/guide/build.html#library-mode lib: { // Specify the entry-point. entry: 'src/index.ts', // Electron supports CommonJS. formats: ['cjs'], }, rollupOptions: { external: [ // Exclude all Electron imports from the build. 'electron', // Exclude Node internals from the build. ...builtinModules.flatMap((p) => [p, `node:${p}`]), ], output: { // Specify the name pattern of the file, which will be `index.cjs` in our case. entryFileNames: '[name].cjs', }, }, }, })

Create a sample file that will add a context-bridge API to our renderer process:

$ mkdir packages/preload/src && touch packages/preload/src/index.ts
// packages/preload/src/index.ts import { contextBridge, shell } from 'electron' // Add a `window.api` object inside the renderer process with the `openUrl` // function. contextBridge.exposeInMainWorld('api', { // Open an URL into the default web-browser. openUrl: (url: string) => shell.openExternal(url), })

Define 1 new script inside our package.json, to build the preload process source-code:

// package.json { // ... "scripts": { // ... "build:preload": "tsc -p packages/preload/tsconfig.json --noEmit && vite build packages/preload" } }

About the build:preload command:

  • tsc -p packages/preload/tsconfig.json --noEmit uses tsc to verify that there is no TypeScript errors in the preload package.
  • vite build packages/preload uses vite build to build the preload package using packages/preload/vite.config.ts.

Main package

Main package is the heart of our Electron application.

Initialize the following files inside packages/main:

$ touch packages/main/tsconfig.json
// packages/main/tsconfig.json { "compilerOptions": { "target": "esnext", "useDefineForClassFields": true, "module": "esnext", "moduleResolution": "node", "strict": true, "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true, "isolatedModules": true, "lib": ["esnext", "dom"], "types": ["node", "vite/client"] }, "include": ["./src/**/*.ts", "./src/**/*.d.ts"], "references": [{ "path": "../../tsconfig.node.json" }] }
$ touch packages/main/vite.config.ts
// packages/main/vite.config.ts import { builtinModules } from 'node:module' import { defineConfig } from 'vite' export default defineConfig({ envDir: process.cwd(), root: __dirname, base: './', build: { outDir: '../../dist/main', emptyOutDir: true, target: 'node14', sourcemap: true, // Build main in "lib" mode of Vite. lib: { // Define the entry-point. entry: './src/index.ts', // Define the build format, Electron support CJS. formats: ['cjs'], }, rollupOptions: { external: [ // Once again exclude Electron from build output. 'electron', // Exclude Node builtin modules. ...builtinModules.flatMap((p) => [p, `node:${p}`]), ], output: { // Will be named `index.cjs`. entryFileNames: '[name].cjs', }, }, }, })

Create a simple Electron app at packages/main/src/index.ts:

$ mkdir packages/main/src && touch packages/main/src/index.ts
// packages/main/src/index.ts import { join } from 'node:path' import { app, BrowserWindow } from 'electron' const isSingleInstance = app.requestSingleInstanceLock() if (!isSingleInstance) { app.quit() process.exit(0) } async function createWindow() { const browserWindow = new BrowserWindow({ show: false, width: 1200, height: 768, webPreferences: { webviewTag: false, // Electron current directory will be at `dist/main`, we need to include // the preload script from this relative path: `../preload/index.cjs`. preload: join(__dirname, '../preload/index.cjs'), }, }) // If you install `show: true` then it can cause issues when trying to close the window. // Use `show: false` and listener events `ready-to-show` to fix these issues. // https://github.com/electron/electron/issues/25012 browserWindow.on('ready-to-show', () => { browserWindow?.show() }) // Define the URL to use for the `BrowserWindow`, depending on the DEV env. const pageUrl = import.meta.env.DEV ? 'http://localhost:3000' : new URL('../dist/renderer/index.html', `file://${__dirname}`).toString() await browserWindow.loadURL(pageUrl) return browserWindow } app.on('second-instance', () => { createWindow().catch((err) => console.error('Error while trying to prevent second-instance Electron event:', err) ) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', () => { createWindow().catch((err) => console.error('Error while trying to handle activate Electron event:', err) ) }) app .whenReady() .then(createWindow) .catch((e) => console.error('Failed to create window:', e))

Let's add more scripts inside our package.json:

// package.json { // ... // Electron will look for this entry-point when starting. "main": "dist/main/index.cjs", "scripts": { // ... // Build the main process. "build:main": "tsc -p packages/main/tsconfig.json --noEmit && vite build packages/main", // The script that will build all our packages. "build": "npm run build:renderer && npm run build:preload && npm run build:main", // When everything is built, run the "start" script to see your Electron app in PRODUCTION mode. "start": "electron ." } }

Custom dev-server script

If we want to hot-reload our Electron app with all these packages, we have to create a custom dev-server script.

Vite.js provides a programmatic API for this specific use-case:

$ mkdir scripts && touch scripts/dev-server.ts
// scripts/dev-server.ts import type { InlineConfig, ViteDevServer } from 'vite' import type { ChildProcessWithoutNullStreams } from 'child_process' import electronPath from 'electron' import { build, createLogger, createServer } from 'vite' import { spawn } from 'child_process' // Shared config across multiple build watchers. const sharedConfig: InlineConfig = { mode: 'development', build: { watch: {} }, } /** * Create a Vite build watcher that automatically recompiles when a file is * edited. */ const getWatcher = (name: string, configFilePath: string, writeBundle: any) => build({ ...sharedConfig, configFile: configFilePath, plugins: [{ name, writeBundle }], }) /** * Setup a watcher for the preload package. */ const setupPreloadWatcher = async (viteServer: ViteDevServer) => getWatcher('reload-app-on-preload-package-change', 'packages/preload/vite.config.ts', () => { // Send a "full-reload" page event using Vite WebSocket server. viteServer.ws.send({ type: 'full-reload' }) }) /** * Setup the `main` watcher. */ const setupMainWatcher = async () => { const logger = createLogger('info', { prefix: '[main]' }) let spawnProcess: ChildProcessWithoutNullStreams | null = null return getWatcher('reload-app-on-main-package-change', 'packages/main/vite.config.ts', () => { if (spawnProcess !== null) { spawnProcess.off('exit', () => process.exit(0)) spawnProcess.kill('SIGINT') spawnProcess = null } // Restart Electron process when main package is edited and recompiled. spawnProcess = spawn(String(electronPath), ['.']) }) } (async () => { try { const rendererServer = await createServer({ ...sharedConfig, configFile: 'packages/renderer/vite.config.ts', }) await rendererServer.listen() rendererServer.printUrls() await setupPreloadWatcher(rendererServer) await setupMainWatcher() } catch (err) { console.error(err) } })().catch((err) => console.error(err))

Install the following dev-dependencies to run the dev-script:

npm install --save-dev cross-env ts-node

Add a script to start the dev-server inside the package.json:

// package.json { // ... "scripts": { // ... "dev": "cross-env NODE_ENV=development ts-node scripts/dev-server.ts" } }

The dev-server should be running with hot-reload using various Vite processes for each package (main, renderer, preload).

Conclusion

Vite is an awesome tool that can do anything we want from web-build to library bundling and even Electron apps.

I hope this article will help you to understand how to build an Electron app with the awesome performance of Vite.