node.jstypescriptelectronviteelectron-forge

How can I use native Node modules in my packaged Electron application?


I know this question have been asked quite frequently all over the web, but I can't find any answer that resolved my issue, so I'll try to ask with my own specifications.

I am building a desktop application in Typescript using Electron and Vite. I'm not sure it is relevant, but I'm using React for the renderer part. I got the base on the Electron Forge website.

In this application, I need a database, so I choose better-sqlite3, and Kysely for my sanity. better-sqlite3 is a native Node module, but it shouldn't be a problem when packaging the application with Eletron-Forge. However, I can't seem to make it work.

First of all, in developement I had to add options to my Vite config to explicitly point better-sqlite3 as an external dependency :

export default defineConfig({
    [...]
    build: {
        rollupOptions: {
            external: ["better-sqlite3"]
        }
    },
    [...]
});

From there, it works like a charm when I run the application :

npm run start => electron-forge start

However, if I package the application :

npm run package => electron-forge package

Running the package application displays an error :

Uncaught Exception:
Error: Cannot find module 'better-sqlite3'
Require stack:
-
[...]\out\app-win32-x64\ressources\app.asar\.v...\main.js
-

I can change Vite configuration to not consider better-sqlite3 as external package in production :

export default defineConfig({
    [...]
    build: {
        rollupOptions: {
            external: process.env.NODE_ENV === "development" ? ["better-sqlite3"] : []
        }
    },
    [...]
});

But the package isn't found when I run the application :

Uncaught Exception : 
Error : Could not dynamically require "[...]\build\better_sqlite3.node".
Please configure dynamicRequireTargets or/and ignoreDynamicRequires option of @rollup/plugin-commonjs approriately for this require call to work.

Which is strange, since Electron Forge should use tools like electron-rebuild to ensure everything works well without much configuration. I tried with @electron-forge/plugin-auto-unpack-natives as pointed in the doc, but to no avail.

How can I import better-sqlite3 in my packaged application ?

Package.json :

{
  "name": "app",
  "productName": "app",
  "version": "1.0.0",
  "description": "My Electron application description",
  "main": ".vite/build/main.js",
  "scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "publish": "electron-forge publish",
    "lint": "eslint --ext .ts,.tsx ."
  },
  "devDependencies": {
    "@electron-forge/cli": "^7.6.0",
    "@electron-forge/maker-deb": "^7.6.0",
    "@electron-forge/maker-rpm": "^7.6.0",
    "@electron-forge/maker-squirrel": "^7.6.0",
    "@electron-forge/maker-zip": "^7.6.0",
    "@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
    "@electron-forge/plugin-fuses": "^7.6.0",
    "@electron-forge/plugin-vite": "^7.6.0",
    "@electron/fuses": "^1.8.0",
    "@types/better-sqlite3": "^7.6.12",
    "@types/crypto-js": "^4.2.2",
    "@types/electron-squirrel-startup": "^1.0.2",
    "@types/pg": "^8.11.10",
    "@types/react": "^19.0.4",
    "@types/react-dom": "^19.0.2",
    "@typescript-eslint/eslint-plugin": "^5.62.0",
    "@typescript-eslint/parser": "^5.62.0",
    "electron": "33.3.1",
    "eslint": "^8.57.1",
    "eslint-import-resolver-typescript": "^3.7.0",
    "eslint-plugin-import": "^2.31.0",
    "prettier": "^3.4.2",
    "ts-node": "^10.9.2",
    "vite": "^5.4.11"
  },
  "keywords": [],
  "author": "Me",
  "license": "MIT",
  "dependencies": {
    "@emotion/react": "^11.14.0",
    "@emotion/styled": "^11.14.0",
    "@mui/icons-material": "^6.4.1",
    "@mui/material": "^6.3.1",
    "@mui/x-date-pickers": "^7.24.0",
    "@reduxjs/toolkit": "^2.5.0",
    "@types/react-redux": "^7.1.34",
    "axios": "^1.7.9",
    "better-sqlite3": "^11.8.1",
    "buffer": "^6.0.3",
    "crypto-js": "^4.2.0",
    "dayjs": "^1.11.13",
    "electron-is-dev": "^3.0.1",
    "electron-squirrel-startup": "^1.0.1",
    "flag-icons": "^7.3.2",
    "fs-extra": "^11.2.0",
    "i18next": "^24.2.1",
    "kysely": "^0.27.5",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-hook-form": "^7.54.2",
    "react-i18next": "^15.4.0",
    "react-redux": "^9.2.0",
    "react-router": "^7.1.1",
    "react-toastify": "^11.0.3",
    "react-webcam": "^7.2.0",
    "typescript": "^5.7.3"
  }
}

forge.config.ts :

const config: ForgeConfig = {
    packagerConfig: {
        asar: true
    },
    rebuildConfig: {},
    makers: [
        new MakerSquirrel({}),
        new MakerZIP({}, ["darwin"]),
        new MakerRpm({}),
        new MakerDeb({})
    ],
    plugins: [
        new VitePlugin({
            build: [
                {
                    entry: "src/main.ts",
                    config: "vite.config.ts",
                    target: "main"
                },
                {
                    entry: "src/preload.ts",
                    config: "vite.config.ts",
                    target: "preload"
                }
            ],
            renderer: [
                {
                    name: "main_window",
                    config: "vite.config.ts"
                }
            ]
        }),
        new FusesPlugin({
            version: FuseVersion.V1,
            [FuseV1Options.RunAsNode]: false,
            [FuseV1Options.EnableCookieEncryption]: true,
            [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
            [FuseV1Options.EnableNodeCliInspectArguments]: false,
            [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
            [FuseV1Options.OnlyLoadAppFromAsar]: true
        })
    ]
};

export default config;

vite.config.ts :

export default defineConfig({
    server: {
        port: 3000,
        proxy: {
            [`${API_BASE_URL}`]: {
                target: "http://localhost:8080",
                changeOrigin: true
            }
        }
    },
    build: {
        rollupOptions: {
            external: process.env.NODE_ENV === "development" ? ["better-sqlite3"] : []
        }
    },
    resolve: {
        alias: {
            "@": resolve(__dirname, "./src"),
            "@view": resolve(__dirname, "./src/view"),
            "@viewModel": resolve(__dirname, "./src/viewModel"),
            "@model": resolve(__dirname, "./src/model"),
            "@database": resolve(__dirname, "./src/database"),
            "@assets": resolve(__dirname, "./src/view/assets")
        }
    }
});

Edit : I didn't found an answer yet, even with Maneet answer. I looked towards the build options for quite a while now, but since I found nothing I wonder if it has something to do with the way I call better-sqlite3 ? Here is the file that calls better-sqlite3 :

database.ts

import { Kysely, SqliteDialect } from "kysely";
import SQLite from "better-sqlite3";
import { RecordTable } from "@model/records/types";
import { CredentialsTable } from "@model/credentials/types";
import { StructureTable } from "@model/structure/types";

export interface Database {
    records: RecordTable;
    credentials: CredentialsTable;
    structures: StructureTable;
}

const databaseFileLocation: string = "./database.db";

const appDatabase = new SQLite(databaseFileLocation);
appDatabase.pragma("journal_mode = WAL");

const dialect: SqliteDialect = new SqliteDialect({
    database: appDatabase
});

export const database: Kysely<Database> = new Kysely<Database>({
    dialect
});

Then I use the exported 'database' object in my main process through a contextBridge exposed in my preload file.

preload.ts

import { contextBridge, ipcRenderer } from "electron";
import { NewRecord } from "@model/records/types";
import { NewStructure } from "@model/structure/types";
import {
    IPC_RECORDS_CREATE_RECORD,
    IPC_RECORDS_READ_ALL_RECORDS
} from "@model/database/records/constants";
import {
    IPC_CREDENTIALS_CREATE_USER,
    IPC_CREDENTIALS_READ_USER_FOR_LOGGING
} from "@model/database/credentials/cosntants";
import {
    IPC_STRUCTURES_CREATE_STRUCTURE,
    IPC_STRUCTURES_READ_ALL_STRUCTURES
} from "@model/database/structures/constants";
import { NewCredentials } from "@model/credentials/types";

contextBridge.exposeInMainWorld("electronAPI", {
    writeData: (data: string | ArrayBuffer, fileName: string) =>
        ipcRenderer.invoke("writeData", data, fileName),
    records: {
        readAllRecords: () => ipcRenderer.invoke(IPC_RECORDS_READ_ALL_RECORDS),
        createRecord: (record: NewRecord) => ipcRenderer.invoke(IPC_RECORDS_CREATE_RECORD, record)
    },
    credentials: {
        readUserForLogging: (user: string, password: string) =>
            ipcRenderer.invoke(IPC_CREDENTIALS_READ_USER_FOR_LOGGING, user, password),
        createUser: (credentials: NewCredentials) =>
            ipcRenderer.invoke(IPC_CREDENTIALS_CREATE_USER, credentials)
    },
    structures: {
        readAllStructures: () => ipcRenderer.invoke(IPC_STRUCTURES_READ_ALL_STRUCTURES),
        createStructure: (structure: NewStructure) =>
            ipcRenderer.invoke(IPC_STRUCTURES_CREATE_STRUCTURE, structure)
    }
});

Solution

  • Finally found how to do it ! As Maneet pointed in their answer, better-sqlite3 wasn't packaged, and thus the required call would fail.

    I found here and there mentions to packagerConfig.extraRessource property in forge.config.ts file, which will copy asked files outside the asar archive. However, I needed better-sqlite3 files to be inside the asar archive.

    After an astronomic amount of research, I found the answer on this page. I had to adapt it a little, but it works fine. The idea is to use an Electron-forge hook to copy what we want in a temp file, before it gets archived int app.asar. This solution only requires changes on forge.config.ts file :

    import type { ForgeConfig } from "@electron-forge/shared-types";
    import { MakerSquirrel } from "@electron-forge/maker-squirrel";
    import { VitePlugin } from "@electron-forge/plugin-vite";
    import { FusesPlugin } from "@electron-forge/plugin-fuses";
    import { FuseV1Options, FuseVersion } from "@electron/fuses";
    import { resolve, join, dirname } from "path";
    import { copy, mkdirs } from "fs-extra";
    
    const config: ForgeConfig = {
        packagerConfig: {
            asar: true
        },
        rebuildConfig: {},
        hooks: {
            // The call to this hook is mandatory for better-sqlite3 to work once the app built
            async packageAfterCopy(_forgeConfig, buildPath) {
                const requiredNativePackages = ["better-sqlite3", "bindings", "file-uri-to-path"];
    
                // __dirname isn't accessible from here
                const dirnamePath: string = ".";
                const sourceNodeModulesPath = resolve(dirnamePath, "node_modules");
                const destNodeModulesPath = resolve(buildPath, "node_modules");
    
                // Copy all asked packages in /node_modules directory inside the asar archive
                await Promise.all(
                    requiredNativePackages.map(async (packageName) => {
                        const sourcePath = join(sourceNodeModulesPath, packageName);
                        const destPath = join(destNodeModulesPath, packageName);
    
                        await mkdirs(dirname(destPath));
                        await copy(sourcePath, destPath, {
                            recursive: true,
                            preserveTimestamps: true
                        });
                    })
                );
            }
        },
        makers: [new MakerSquirrel({})],
        plugins: [
            new VitePlugin({
                build: [
                    {
                        entry: "src/main.ts",
                        config: "vite.config.ts",
                        target: "main"
                    },
                    {
                        entry: "src/preload.ts",
                        config: "vite.config.ts",
                        target: "preload"
                    }
                ],
                renderer: [
                    {
                        name: "main_window",
                        config: "vite.config.ts"
                    }
                ]
            }),
            new FusesPlugin({
                version: FuseVersion.V1,
                [FuseV1Options.RunAsNode]: false,
                [FuseV1Options.EnableCookieEncryption]: true,
                [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
                [FuseV1Options.EnableNodeCliInspectArguments]: false,
                [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
                [FuseV1Options.OnlyLoadAppFromAsar]: true
            })
        ]
    };
    
    export default config;
    

    For each string in requiredNativePackages list, the hook will look for a directory with the same name in node_modules, and copy this in a directory named node_modules inside the temp directory which will be turned into an archive right after. We need bindings and file-uri-to-path packages in top of better-sqlite3 because they're direct dependencies.