Recently, we built NeetoRecord, a loom alternative. The desktop application was built using Electron. In a series of blogs, we capture how we built the desktop application and the challenges we ran into. This blog is part 8 of the blog series. You can also read about part 1, part 2, part 3, part 4, part 5, part 6 and part 7.
Native modules allow developers to access low-level APIs like hardware interaction, native GUI components, or other system-specific features. Because Electron applications run across different platforms (Windows, macOS, Linux), native modules must be compiled for each target platform. This can introduce challenges in cross-platform development.
To simplify this process, Electron provides tools like electron-rebuild to automate the recompilation of native modules against Electron's custom Node.js environment, ensuring compatibility and stability in the Electron application.
How to use electron-rebuild
electron-rebuild can automatically determine the version of Electron and handle the manual steps of downloading headers and rebuilding native modules for our app.
To do a manual rebuild, run the below command.
1./node_modules/.bin/electron-rebuild 2 3// If we are on windows 4.\node_modules\.bin\electron-rebuild.cmd
This process should be run after each native package is installed. We can add this command to the postinstall script to automate it.
1"scripts": { 2 "postinstall": "./node_modules/.bin/electron-rebuild" 3 //...others 4}
Since Electron uses Chromium browser windows as the user interface, we need to exclude any native modules from being bundled in the renderer process, which runs inside the browser window. To achieve this, we need to separate frontend modules from native modules and install them in separate node_modules folders.
Two package.json structure
To tackle this problem, Electron developers started using two package.json structure, where the first one, which sits at the root of the project, includes the dependencies that are needed for the user interface and all the devDependencies that are needed to develop, build, and package the application.
1// root package.json 2{ 3 "name": "my-app", 4 "version": "1.0.0", 5 "description": "A sample application", 6 "license": "Apache-2.0", 7 "main": "./src/main/main.mjs", 8 "dependencies": { 9 "react": "^18.2.0", 10 "react-router-dom": "5.3.3" 11 }, 12 "devDependencies": { 13 "electron": "^31.2.1", 14 "electron-builder": "^25.0.1" 15 } 16}
And a second package.json file, located at ./app/package.json, includes all the native dependencies that should only run in a Node.js environment. The electron-rebuild postinstall script we discussed should be added to the ./app/package.json.
1// ./app/package.json 2{ 3 "name": "my-app", 4 "version": "1.0.0", 5 "description": "A sample application", 6 "license": "Apache-2.0", 7 "main": "./dist/main/main.js", 8 "scripts": { 9 "postinstall": "./node_modules/.bin/electron-rebuild" 10 }, 11 "dependencies": { 12 "sqlite3": "^5.1.7", 13 "sharp": "^0.33.5" 14 } 15}
We can add a postinstall script in the root to automatically install the dependencies listed in ./app/package.json when installing the root package.json.
1// root package.json 2{ 3 "name": "my-app", 4 "version": "1.0.0", 5 "description": "A sample application", 6 "license": "Apache-2.0", 7 "main": "./src/main/main.mjs", 8 "dependencies": { 9 "react": "^18.2.0", 10 "react-router-dom": "5.3.3", 11 } 12 "devDependencies": { 13 "electron": "^31.2.1", 14 "electron-builder": "^25.0.1", 15 }, 16 "scripts": { 17 "postinstall": "yarn --cwd ./app install" 18 } 19}
Now, if we run the yarn install from the root, after installing root dependencies, it will install native dependencies in the app/ folder and then rebuild the native packages. All in one command.
When building, we should output the frontend bundles to the app/dist folder. This way, when packaging, both our native packages and other dependencies will be contained within the app folder. Read this blog to learn more about how to build and publish an Electron application.
1electron-app 2├── assets 3├── app 4│ └── node_modules 5│ └── dist 6│ └── package.json 7├── src 8├── node_modules 9├── package.json
This approach works well while packaging the app, but during development, how can we access the native modules? To solve this, we need to create a symlink from app/node_modules to src/main/node_modules. We can create a script to handle this.
1// ./scripts/link-modules.mjs 2import fs from "fs"; 3 4fs.symlinkSync("./app/node_modules", "./src/main/node_modules", "junction");
We can run this script in postinstall as well:
1// ./app/package.json 2{ 3 "name": "my-app", 4 "version": "1.0.0", 5 "description": "A sample application", 6 "license": "Apache-2.0", 7 "main": "./dist/main/main.js", 8 "scripts": { 9 "link-modules": "node ../scripts/link-modules.mjs", 10 "postinstall": "./node_modules/.bin/electron-rebuild && yarn link-modules" 11 }, 12 "dependencies": { 13 "sqlite3": "^5.1.7", 14 "sharp": "^0.33.5" 15 } 16}
Problem with two package.json structure
In most JavaScript projects, metadata such as version, name, and description is typically stored in the root package.json. However, in this case, when packaging the app, we'll be including ./app/package.json instead of the root package.json. This means all metadata should be in ./app/package.json rather than in the root file.
This approach poses a challenge, particularly with tasks like automatic version bumping. When updating the version or other metadata, changes need to be made in two places, which can lead to inconsistencies.
To simplify this process and avoid maintaining two separate package.json files, we can dynamically create ./app/package.json, allowing us to manage everything in one place. Since we cannot add native dependencies directly to the root dependencies, we can introduce a new field called nativeDependencies. When dynamically generating ./app/package.json, we can copy the nativeDependencies into the dependencies field of ./app/package.json.
This can be accomplished by creating a script to automate the process:
1// ./script/create-native-package-json.js 2import fse from "fs-extra"; 3 4const packageJson = JSON.parse(fse.readFileSync("./package.json", "utf8")); 5 6const APP_DIR = "./app/"; 7 8const releaseJson = { 9 name: packageJson.name, 10 version: packageJson.version, 11 description: packageJson.description, 12 license: packageJson.license, 13 author: packageJson.author, 14 main: "./dist/main/main.js", 15 scripts: { 16 postinstall: "/node_modules/.bin/electron-rebuild && yarn link-modules", 17 "link-modules": "node ../scripts/link-modules.mjs", 18 }, 19 dependencies: packageJson.nativeDependencies, 20}; 21 22fse.mkdirSync(APP_DIR, { recursive: true }); 23 24fse.writeFileSync( 25 APP_DIR + "package.json", 26 JSON.stringify(releaseJson, null, 2) 27);
In the script, we first fetch the root package.json, create a JSON file, and copy all the metadata. We then update the main field to point to the compiled version of main.js, copy nativeDependencies into the dependencies, and add the postinstall script for electron-rebuild and node_modules linking we discussed earlier.
We can run this script in the root postinstall, so that if we make any changes to package.json, it will be updated in ./app/package.json during yarn install.
1// root package.json 2"scripts": { 3 "nativeInstall": "yarn --cwd ./app install", 4 "postinstall": "node ./script/create-native-package-json.js && yarn nativeInstall", 5 //...others 6}