electron/electron

Support Node's ES Modules

Jamesernator opened this issue ยท 275 comments

Important

Support for ES Modules in Electron will land in Electron 28 and will be available in the electron-nightly released on August 31st

๐Ÿฅณ

Original Issue Below

Preflight Checklist

  • I have read the Contributing Guidelines for this project.
  • I agree to follow the Code of Conduct that this project adheres to.
  • I have searched the issue tracker for a feature request that matches the one I want to file, without success.

Feature Request

As of Node 13.3 ES modules have been unflagged (albeit still experimental), it would be nice to be able to use ES modules as supported in electron.

Currently when trying to run electron with an ES entry point (either .mjs or .js+"type": "module") electron fails to run.

For example:

> electron-test@1.0.0 start /home/jamesernator/Projects/electron-test
> electron .

App threw an error during load
/home/jamesernator/Projects/electron-test/main.js:1
import electron from 'electron';
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at Module._compile (internal/modules/cjs/loader.js:815:22)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:892:10)
    at Module.load (internal/modules/cjs/loader.js:735:32)
    at Module._load (internal/modules/cjs/loader.js:648:12)
    at Module._load (electron/js2c/asar.js:717:26)
    at Function.Module._load (electron/js2c/asar.js:717:26)
    at loadApplicationPackage (/home/jamesernator/Projects/electron-test/node_modules/electron/dist/resources/default_app.asar/main.js:109:16)
    at Object.<anonymous> (/home/jamesernator/Projects/electron-test/node_modules/electron/dist/resources/default_app.asar/main.js:155:9)
    at Module._compile (internal/modules/cjs/loader.js:880:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:892:10)

Alternatives Considered

This is primarily a request for support, the alternative is not supporting it but that would be unfortunate given ES modules will be officially supported in both browsers and Node but not usable by electron without additional tooling.

Additional Information

ES Modules are still new (and experimental) so actual implementation might be delayed (or put behind flag) however starting to consider implementation considerations now would be helpful for supporting this more readily in future.

FWIW, Electron won't be supporting Node 13 as our policy is to only roll in LTS versions of Node. So, while we definitely want to support ESM, this isn't going to be happening until Node 14.

Sure, following LTS makes sense for production releases, but there's no harm in creating a v10 branch and adding ESM support now with Node v13 which can be rolled over to v14 once it's out in April 2020.

@pauliusuza "no harm" indeed, except that we would then have to make sure we backport every change from master into the v10 branch, which is a significant burden on the maintainer team.

We'll do like we've done for all previous release branches, and branch v10 once v9 is stable. See https://electronjs.org/docs/tutorial/electron-timelines.

As a workaround, you can use the esm package for the time being. Just install it, import it, then any file you import after that can be an ES Module.

Then, once Node 14 is out and Electron upgrades, all you need to do is modify the entry point not to import esm, and to import your ES Module directly. All the rest of the code base will remain untouched.

That got the job done, Thanks! @trusktr
I have a TS monorepo project for desktop and web and every possible combination of tsconfig resulted in one of both being broken. Adding ESM fixed the issue ๐Ÿ‘

awkj commented

wish electron use deno replace node

@nornagon ES modules unflagged is expected to land in April for Node 12. For more details, see nodejs/modules#450.

As a workaround, you can use the esm package for the time being. Just install it, import it, then any file you import after that can be an ES Module.

Then, once Node 14 is out and Electron upgrades, all you need to do is modify the entry point not to import esm, and to import your ES Module directly. All the rest of the code base will remain untouched.

I don't quite undestand how you did this. Can you give an example of how this workaround is supposed to work?

Edit: nevermind, I got it working. My code looks like this now (if anyone's wondering):

./main.js:

require = require("esm")(module)
module.exports = require("./js/electronMain.js")

with my Electron (main process) startup code in ./js/electronMain.js

Node 12.17 removed necessity to enable -experimental-modules flag, so I wonder if after incorporating #23789 it will just start working out of the box. And which electron release will get it... :)

dy commented

It is node@14.4 already, with stabilized support for ESM. It is time for the ecosystem to gradually switch to common standard. Would be really nice if electron enabled modules. esm doesn't work with node@13+.

As mentioned earlier in the thread, we will support this when Electron includes a version of node which supports it, and not before.

Electron only includes LTS releases of Node, and does not upgrade Node versions in stable releases. Node 14 won't be LTS until October, so the earliest version of Electron that could include Node 14 LTS is Electron 12.

Ah, in that case Electron 11 will likely include support for it.

@nornagon I would expect some stuff to break without deeper testing of native ESM with Electron's API. Will the entire Electron API be supported or parts of it in ESM in Electron 11?

Full transparency we might turn off node's ESM loader in Electron 11 and it will almost certainly be disabled completely in renderer processes. Still reviewing exactly what the implications of this loader, it's implementation and it's interactions with Chromium exactly are and when we know more we'll share it but I want to set the expectations quite clearly here. This is not a simple flag or switch we can just turn on and move on with our lives, there are significant security, performance and compatibility concerns that we need to address here.

@MarshallOfSound @nornagon While I understand the challenge of integrating Node and Chromium on a periodic basis, integrating ESM into Electron will be a challenge in itself.

I believe the spirit of this current issue is to sort out these security, performance and compatibility issues before ESM lands. Maybe it is impossible to do beforehand.

Any ideas on how to proceed? How should this be tackled?

and it will almost certainly be disabled completely in renderer processes

Frankly, that's exactly the opposite of what I expected to hear.

@pauliusuza Weback already converts all your imports into one file, so you can write things like import { ipcRenderer } from 'electron' in your client site code and it works.

Latest version of Electron 9.0.5 uses Node.js 12.14.1, so according to the docs https://nodejs.org/dist/v12.14.1/docs/api/esm.html the only way to use ESM modules is to pass the --experimental-modules flag. But electron won't allow this flag, so we are stuck.

According to https://nodejs.org/en/about/releases/ Node v14 will be LTS at the end of October
Also, according to Electron's timeline https://www.electronjs.org/docs/tutorial/electron-timelines , releasing a version every 3 months, Node v14 will be part of Electron 11 released at the end of November.

If we get ESM module in Electron before Christmas it would be a great deal, but we shouldn't hold our breaths until then.

@cata-code --experimental-modules has been removed in 12.17.0 (LTS). Even if this is true, Electron might disable this feature because of security, performance and compatibility issues.

If there is such a precedent, I'm not convinced native ESM would land with 14.0.0 in Electron. There is much work to verify everything beforehand and no plan that I'm aware of.

Please take note of the comment history in this issue.

Ok, so instead of waiting for this feature, we can use imports in electron main files with the help of webpack.

This is how I set it up for my app, using webpack for both client side and electron main files

webpack.config.cjs

const path = require('path')
const CircularDependencyPlugin = require('circular-dependency-plugin')
const webpack = require('webpack')
const nodeExternals = require('webpack-node-externals')

module.exports = [
  {
    target: 'web',
    entry: './src/js/index.js',
    output: {
      filename: 'script.js',
      path: path.resolve(__dirname, 'app/js')
    },
    plugins: [
      new CircularDependencyPlugin(),
      new webpack.ExternalsPlugin('commonjs', [
        'electron'
      ])
    ]
  },
  {
    target: 'electron-main',
    entry: './src/electron/index.js',
    output: {
      filename: 'index.js',
      path: path.resolve(__dirname, 'app')
    },
    plugins: [
      new CircularDependencyPlugin()
    ],
    externals: [nodeExternals()]
  }
]

index.js

import { app, BrowserWindow, shell } from 'electron'
import contextMenu from 'electron-context-menu'

async function createWindow () {
 ...
}

Foo.js

import { ipcMain } from 'electron'

export default {
  foo () {
    ipcMain.handle('foo') ...
  }
}

I was using solution mentioned by @leontepe for quite some time and it allows me to use esm modules and imports without problems by using "esm" node module and this little wrapper (main_esm.js) for entrypoint (main.js):

var _require = require("esm")(module);
_require('./main.js');

ironically at runtime Electron complains that using require in esm modules, which apparently it thinks this file is (sic!), isn't supported:

(node:19076) Warning: require() of ES modules is not supported.
require() of main_esm.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename main_esm.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from package.json.

Hope that whatever version of Electron that will be branched from current master (which already includes #23789 forcing support of esm in node AFAICT) will allow me to remove above little hack.

@kskalski I had a look at the source code for the esm module and when I saw this https://github.com/standard-things/esm/tree/master/src I realized how heavy the hack is. Also this is run every time you start the app.

I rather use webpack and compile it once, and then the app is optimized every time you run it.

Besides you are most likely using webpack for the client side code. So when webpack is run, it builds both main and render/client files.

@kskalski that is total wrong no one should need to use webpack so the solution is to use npm module ESM and wait till they fixed #24971

When Electron upgrades to using Node 14, would I be able to do this:

my-app/package.json
{ "type": "module" }

my-app/index.js
const path = await import('path');

And then run:
electron index.js?

Would I also be able to use Node's --experimental-loader option? Such as:
electron index.js --js-flags=--experimental-loader loader.js

Would love this. How can I help?

@kirkouimet you could upgrade the nodejs version that is able to build electron :)

@kirkouimet Not sure about the --experimental-loader, but Node 12.17.0 has ES modules without any flag. Would love to learn about a roadmap/plans for it in Electron.

@frank-ubi electron will simply update the nodejs patches and then release it as always.

@frank-dspeed actually no... see a bit higher (#21457 (comment)):

Full transparency we might turn off node's ESM loader in Electron 11 and it will almost certainly be disabled completely in renderer processes. Still reviewing exactly what the implications of this loader, it's implementation and it's interactions with Chromium exactly are and when we know more we'll share it but I want to set the expectations quite clearly here. This is not a simple flag or switch we can just turn on and move on with our lives, there are significant security, performance and compatibility concerns that we need to address here.

@frank-ubi Oh Ok nice to see that i hated the electron concepts anyway NWJS is then the successor :) it supports ESM and works.

Please any solution, i tried @cata-code and @leontepe workaround

import { app, protocol, BrowserWindow } from 'electron'
^^^^^^

SyntaxError: Cannot use import statement outside a module
at Module._compile (internal/modules/cjs/loader.js:895:18)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1004:10)
at Module.load (internal/modules/cjs/loader.js:815:32)
at Module._load (internal/modules/cjs/loader.js:727:14)
at Function.Module._load (electron/js2c/asar.js:769:28)

@samaasi That is to be expected, Electron bundles its own version of Node that is different from the one you have installed locally.

You will need a hackish solution to make it work in meantime. You could use esm webpack/rollup or simply wait. ๐Ÿ˜ž

@samaasi Make sure your package.json contains "type": "module" and your js files have the extension .js not .cjs

@samaasi Make sure your package.json contains "type": "module" and your js files have the extension .js not .cjs

Actually what I found after upgrading to Electron 10 is that even with esm module you can't use 'type': 'module', removing it allowed me to continue using the hack mentioned above.

My solution is not about using the esm npm module, but by using standard webpack - #21457 (comment)

@kskalski I tried but still having same issue, can't really tell what am doing wrongly.

@samaasi your probally using electron /path/to/Your/app ? to start it? Then it also only works when you use npm esm

I have "electron-quick-start". I have replaced this code:

const {app, BrowserWindow} = require('electron')
const path = require('path')

with

import {app, BrowserWindow} from "electron";
import path from "path";

import {fileURLToPath} from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

and added "type": "module", in packege.json.

And it does not work.

Will this code work in the future? When?

Node 14 LTS starts on 2020-10-27 in one month.

I have a cross-platform project that works on web and Electron. The output of my webpack build becomes the input for my electron build like so: webpack --config webpack/webpack.dev.js && electron desktop/main.js.

And from my electron.config.js:

files: [
    './dist/**/*',
    './main.js'
]

I recently had the need to use some code from my ES6 source code in Electron's main.js entry point. I used this workaound, updated electron.config.js to point at the ESM wrapper for main.js, and got the electron app to compile and run with ES6 imports in main.js. So far so good.

However, I've run into an issue with at least one of my dependencies, which offers a commonJS and ESM version of the library. From it's package.json:

"main": "lib/commonjs/index.js",
"module": "lib/module/index.js",

When I launch the Electron app, I'm seeing errors with a call stack originating from my ES6 src modules imported into main.js, but ending in myDependency/lib/commonjs/somePackageFile.js. Not sure what's going on - it seems like the ES6 files should be importing the module version of the npm package, not the commonjs version. Also, I did not have any issues with this dependency before I applied the workaround.

I tried adding the following to my webpack configuration, but it didn't solve the issue or cause any noticeable change:

module.exports = {
....
    resolve: {
        alias: {
            // Alias my-dependency to try to force Electron to import ES6 module
            'my-depenency': path.resolve(__dirname, 'relative_path_to_node_modules/my-depenency/lib/module/index.js'),
        }
    }
....
}

Any ideas?

You can only use this workaround for now. If defined, you have to remove "type": "module" from your package.json and start all your other node scripts with -r esm instead, for exampe node -r esm app.js

Install the ESM loader from here:
https://www.npmjs.com/package/esm

Define your electron start script like this "app": "electron mainElectronESM.js"

mainElectronESM.js (Main Process, Node CJS)

require = require("esm")(module);
module.exports = require("./mainElectron.js");

mainElectron.js (Main Process, Node ESM)

import { app, BrowserWindow } from "electron";
import * as C from "../support/constants.js";

app.on("ready", () => {
    console.log(`${C.People.DEVELOPER} lives in ${C.Location.CITY}`);
});

Also described here:
https://stackoverflow.com/questions/53538559/sharing-an-esm-js-module-within-node-electron-environment

awkj commented

I notice electron night haved update to node 14.15 LTS, any progress ?

jehon commented

@awkj To try electron with it, you could use electron@11.0.0-beta.19 which is in beta.
According to the release schedule, it should come out stable this month: https://www.electronjs.org/docs/tutorial/electron-timelines

11 beta doesn't use Node 14, so we need to wait for 12, which hopefully will get into beta after 11 releases in ~two weeks from now.

I just tried 12.beta1, which already uses NodeJS 14 and I found the following:

  • specifying "type": "module" in package.json is deemed to fail, since internal bootstrap script in Electron seems to be using commonjs syntax and it's failing because of not using "import" syntax (I suppose it might be fixed by adding custom initialization scripts to handle such mode)
  • without "type": "module" you might achieve the ESM functionality by:
async function myFunc() {
        const { itsMine } = await import('./main.mjs')
}
myFunc()

Currently this approach is problematic for me, since my scripts are generated by Typescript, which can only output .js files, so I really need first option to work.

@kskalski you need to modify the starter app to use dynamic import that always works with esm and cjs

@kskalski you need to modify the starter app to use dynamic import that always works with esm and cjs

You mean modifying those scripts to use import() https://github.com/electron/electron/tree/56d1fafe66f538707c16122fb58e14fdf91cbfbb/build/webpack ?

Ok, I guess you meant I need to update entry point of my app to use dynamic imports (I don't use the default_app, I point to main_esm.js in package.json), which would allow me to avoid renaming .js files to .mjs fies (otherwise I think the workaround above, which in fact is using dynamic import to execute the actual app, seems to work already).

But I'm afraid this means I would need to modify each and single file in my app to use dynamic imports, which seems even more intrusive than renaming files from .js to .mjs - without 'type': 'module' each file is assumed to be commonjs when having .js extension.

What I really need is electron to support 'type': 'module', which probably involves changing the build/webpack initialization scripts, or consistently using .mjs files, which seems feasible depending on compilation / deployment that one use.

@kskalski i would suggest anyway to use only CJS at present with nodejs and you can use inside that dynamic import if you need ESM Support for a module. you win not much with using ESM Directly it is not worth the maintance overhead at present. I am evaluating ESM since many years that is my conclusion the ESM support will evolve over the next 5 years it is nothing to worry about. Simply get your app up and running and your fine.

jehon commented

Currently this approach is problematic for me, since my scripts are generated by Typescript, which can only output .js files, so I really need first option to work.

What about creating a main_esm.cjs (mind the "cjs" extension) as an entry point, and put in it:
import('./output.js')

This way, you can use package.json type=module for .js files, and electronjs...

This is what I am doing in one of my projects

What about creating a main_esm.cjs (mind the "cjs" extension) as an entry point, and put in it:
import('./output.js')

Ah, this works. :) The errors turned out to be about the entry file, not about the electron bootstrap scripts.

This is a step forward, though I got stuck on importing elements of 'electron' module:

import { default as electron } from 'electron';
const { app, screen, ipcMain, dialog, BrowserWindow } = electron;

or other variants of that doesn't work, e.g. 'app' is undefined in runtime.

In package.json I did not set a type and set main to this 'esmloader_electron.js' script:

async function loader() {
    globalThis.electron = await require('electron')
    await import('./main_electron.mjs');
}

loader();

otherwise the electron object was not loadable from inside the mjs file. In addition I needed to change

electron.app.on('ready', ...);

to

electron.app.whenReady().then(...);

hope that helps

Can confirm that the above solution from @PaulFreund is working for me as well.

Omit "type": "module" from the package.json manifest and point the main entrypoint to an ESM loader script, i.e. `"main": "electron_esm.js"

Using Paul's approach in that script of:

(async function () {
  globalThis.electron = await require("electron");
  await import("./main.mjs");
})();

I'm not sure that this is the intended long-term solution for Electron 12 and Node 14. I would think that if your "main" entrypoint is a .mjs file extension OR if you specify "type": "module" in your package.json file that the Electron loader would interpret that correctly without the need for an extra wrapper/loader. Hopefully that's a kink that gets worked out during the beta process.

Hey!

So how is this issue progressing? It seems everything has been in order to make it work for quite some time now. Can we expect it to be resolved in a future release?
Any news would be much appreciated.

Cheers!

FWIW, Electron won't be supporting Node 13 as our policy is to only roll in LTS versions of Node. So, while we definitely want to support ESM, this isn't going to be happening until Node 14.

@nornagon Any progress on this? Current Node LTS version is 14

jehon commented

AFAIK, Electron does support esm, but not as the first file loaded.
You still need to have a cjs file at first, that will load your esm application:

Point your eletron toward a cjs file:
In package.json:

  "type": "module",
  "main": "main.cjs"

In main.cjs:

import('./application.mjs');

After that, to import electron, use this in your esm part:

const require = createRequire(import.meta.url);
const { BrowserWindow, app: electronApp, ipcMain } = require('electron');

Feel free to use the packages.json "type = module" field if you want...

Here is a project (of mine) that use that: https://github.com/jehon/kiosk
See in particular:
package.json: https://github.com/jehon/kiosk/blob/master/package.json#L63
electron main application (cjs): https://github.com/jehon/kiosk/blob/master/main.cjs
main application loaded by previous one (ems): https://github.com/jehon/kiosk/blob/master/server/server.js
electron management: https://github.com/jehon/kiosk/blob/master/server/server-lib-gui.js

Next move for electronjs, is to allow first entry point to be a esm, and to fix the import from electron to work correctly.

awkj commented

I am very disappointed that so far it seems, As long as the node is not dead, electron Will use commonjs forever

@jehon @aaclayton your solutions work when developing but did you manage to make this work even after packaging (electron-builder) with "asar": true?

I'm sorry @dmnsgn, but I am unsure. For reasons unrelated to my desire to use ESM I already have to build the app with asar: false - I can confirm that the built application does bootstrap ESM correctly when ASAR is not used. I can try and give it a try and see what happens with ASAR enabled for testing purposes.

EDIT: can confirm that the application did not work for me when using asar: true as it was unable to resolve the import path to the bootstrapped .mjs file.

I apologize that I didn't realize that when posting my suggestion previously, I've been in a situation where asar isn't a viable choice for my application regardless.

@PaulFreund @aaclayton @jehon What about in the BrowserWindow client code? Or do we use Chrome's <script type=module> for that instead of Node ESM?

EDIT: Browser ESM is in fact an option, but it comes with the usual problem like not understanding node-style module specifiers. I can't get Node ESM to work; it crashes the client as soon as I try to import() an ESM module in a file that I've require()ed.

Basically the trick with the main process (CJS file importing an ESM file) works, but the same trick on the client isn't working for me.

Please note that from April 2021 all stable Node.js versions support ESM, so Open Source Node.js authors are starting to recommend to publish npm packages as ESM-only, which currently doesn't seem to work on Electron:

https://twitter.com/sindresorhus/status/1349294527350149121

https://blog.sindresorhus.com/get-ready-for-esm-aa53530b3f77

Example of ESM-only library:
https://www.npmjs.com/package/files

Having to stick with the require("esm") approach now because of this, which in turn means I can't use optional chaining due to:

standard-things/esm#866

It will be great to see default support of modern module system in electron.

what is the status on ESM support? Electron 12.0.0 has node v14.15+ has runtime and type=module does not seem to be working.

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /Users/damiano/Desktop/tav-client/index.mjs
    at Module.load (internal/modules/cjs/loader.js:933:11)
    at Module._load (internal/modules/cjs/loader.js:776:14)
    at Function.f._load (electron/js2c/asar_bundle.js:5:12684)
    at loadApplicationPackage (/Users/damiano/Desktop/tav-client/node_modules/electron/dist/Electron.app/Contents/Resources/default_app.asar/main.js:110:16)
    at Object.<anonymous> (/Users/damiano/Desktop/tav-client/node_modules/electron/dist/Electron.app/Contents/Resources/default_app.asar/main.js:222:9)
    at Module._compile (internal/modules/cjs/loader.js:1078:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1108:10)
    at Module.load (internal/modules/cjs/loader.js:935:32)
    at Module._load (internal/modules/cjs/loader.js:776:14)
    at Function.f._load (electron/js2c/asar_bundle.js:5:12684)

It's really strange that upgrading to Node 14 runtime is Electron 12's flagship feature, but the lack of support (without a workaround) for server-side ESM is notably absent.

I understand of course if there are major technical hurdles to surmount in order to accomplish this, but it feels like this issue should be getting more attention/discussion.

I managed to create a workaround to make ES modules work in main.js but it seems impossible to make them work in the preload.js.
If anyone has any solutions I would be really interested in having some code snippet!

@SmashingQuasar Can you share your workaround, please? ๐Ÿ™

@SmashingQuasar Can you share your workaround, please? ๐Ÿ™

Of course!

Here is my directory structure so it is clear to everyone reading this:

-> build
|-----> ... (build files)
-> src
|-----> backend
      |-----> main.ts
      |-----> app.ts
|-----> frontend
      |-----> renderer.ts
|-----> middleware
-> build.sh

Here is what you need:

main.ts

(async() => {
    global.Electron = require("electron");
    
    await import("./app.js");
})();

app.ts

import { Kernel } from "./System/Kernel.js";

Kernel.Start(Electron.app);

build.sh

#!/bin/bash
set -euo pipefail

echo "Deleting build folder"
rm -rf build

echo "Building TypeScript"
tsc
echo "TypeScript successfully built"

echo "Converting .js files to .mjs"

FILES=$(find "./build" -name "*.js")

for path in $FILES
do

    echo "Editing $path"

    sed -r -i "s/(import ?[^\"]+\"([^\"]+))\.js/\1.mjs/" "$path"

    if [[ $path =~ /main.js$ ]] || [[ $path =~ /preload.js ]];
    then
        continue
    fi
    
    mv "$path" "${path/%.js/.mjs}"

done

echo "Done converting files to .mjs"

# This line is used to copy non-TS files to the build folder because TS won't export any file that it does not compile.
cp -r src/backend/Resources build/backend/Resources

echo "Copying Resources directory"

Without adapting the build.sh script you need to run it from the root of your project since Bash considers paths and environments relative to the execution location.

This structure allows you to use ES modules anywhere except in the main.ts.

In my code, Kernel is simply a class that I use, you can basically use anything in the app.js file that I defined. My code is just an example. What you should not modify is the main.ts that allows you to use ES modules.

This trick sadly does not work for the middleware (preload.js) and it seems there are no workarounds for this part. I currently have stopped all Electron development because of this as I feel uncomfortable putting more time and effort into a technology that seems to be derailing slowly...

@SmashingQuasar Thank you (and sorry for not reading your answer earlier). So, does your solution works just because you rename all the ES6 modules to .mjs (and main/preload are not ES modules)? So far I've been trying to go the other way and convert main.js and everything it imports to commonjs (via Vite/Rollup). But I'll try your alternative if that fails.

I currently have stopped all Electron development because of this as I feel uncomfortable putting more time and effort into a technology that seems to be derailing slowly...

Yeah, this is kind of sad. I know the maintainers may have other priorities, but come on. Node 14 has been supporting ES modules for a while now, and modern tools like Vite use ES modules for their dev servers, so it makes total sense for Electron to support it too.

Node 14 has been supporting ES modules for a while now, and modern tools like Vite use ES modules for their dev servers, so it makes total sense for Electron to support it too.

Everything is a little more complicated than it may seem at first glance. The node doesn't support the import ES module in the commonjs modules.

If package A (electron, electron-builder, eslint, whatever) is written as a commonjs module and makes a call like require('package B') (your application files, configuration files, etc.), then you cannot use package B as an ES module, until you rewrite package A as an es module.

Want to know more? Here's a little challenge for you:
Add ESM configuration support to this tiny package: develar/read-config-file

@cawa-93 your not correct current node and all future node versions do support importing ESM inside CJS

you need to use dynamic import() for that i

This has been a big pain point for me, as I'd like to use some dependencies which are published only as ESM modules. My only two options seem to be:

  1. Convert my TypeScript + Electron + Webpack + Electron-Builder project to ESM, but for some reason ESM support is severely lacking on all fronts, and I see a lot of finger-pointing in various GitHub issues regarding whose responsibility it is to get ESM working.

  2. Find a way to downgrade all my ESM dependencies to commonjs, or avoid using them entirely.

At the moment it seems like Option 2 is the only realistic option. I sincerely hope this becomes a higher-priority fix.

Node 14 has been supporting ES modules for a while now, and modern tools like Vite use ES modules for their dev servers, so it makes total sense for Electron to support it too.

Everything is a little more complicated than it may seem at first glance. The node doesn't support the import ES module in the commonjs modules.

If package A (electron, electron-builder, eslint, whatever) is written as a commonjs module and makes a call like require('package B') (your application files, configuration files, etc.), then you cannot use package B as an ES module, until you rewrite package A as an es module.

Want to know more? Here's a little challenge for you:
Add ESM configuration support to this tiny package: develar/read-config-file

I don't mean any offense but what you are saying is basically "Electron has a major design flaw and now they are stuck with a huge predictable technical debt that they don't know how to fix".

Node 14 has been supporting ES modules for a while now, and modern tools like Vite use ES modules for their dev servers, so it makes total sense for Electron to support it too.

Everything is a little more complicated than it may seem at first glance. The node doesn't support the import ES module in the commonjs modules.

If package A (electron, electron-builder, eslint, whatever) is written as a commonjs module and makes a call like require('package B') (your application files, configuration files, etc.), then you cannot use package B as an ES module, until you rewrite package A as an es module.

Want to know more? Here's a little challenge for you:
Add ESM configuration support to this tiny package: develar/read-config-file

You mean, doing something like this ?

if (configFile.endsWith(".js") || configFile.endsWith(".mjs"))
{
    result = await import(configFile);
}

@Zamralik thats not correct nodejs lets u import esm modules via

(async function () {
const { property1, property2 } =  await import(''esm-module');
})()
NL33 commented

Edit: nevermind, I got it working. My code looks like this now (if anyone's wondering):

./main.js:

require = require("esm")(module)
module.exports = require("./js/electronMain.js")

with my Electron (main process) startup code in ./js/electronMain.js

Does anyone have a full example of using esm with electron to address this issue? I'm hoping someone can lay out how they did the import of the package they cared about using as well, bc I'm not yet getting it to work. And I also am hoping to find a way to continue to use all the packages I've been using that work already with require.

For example, I've been trying to get the run-applescript package to work, which currently requires an import statement. I have a basic setup: app loads at main.js, and I have some renderers. After I imported esm ($ npm i esm), I did the following:

In main.js, I added:

require = require("esm")(module)
module.exports = require("./main.js")

But to use the package I want, what do I need to do next? Putting in the package in main.js or a renderer.js doesn't work (using either import { runAppleScriptAsync } from 'run-applescript'; or const runAppleScriptAsync = require('run-applescript')).

What else is required?

Based on @SmashingQuasar's post above, we were able to create a very simple workaround:

set "type": "module" in package.json, and

my-project/
  frontend/
    โ€ฆ
    index.html
  backend/
    index.cjs // very important to use `.cjs`
    app.js // regular ESM stuff
(async() => {
  global.electron = require('electron');
  await import('./app.js');
})();

It might be possible to simplify this further, but I haven't explored that yet. As-is, it seems to work just fine (and you can basically just forget about index.cjs).

NL33 commented

Thanks. Can you mix non-esm (packages that use "require") along with esm in the same files with your structure? I'm hoping for a solution that doesn't require too much re-arranging (most the packages don't require esm that I use).

Yes, unless the package intentionally and arbitrarily shits the bed like Electron (in which case, you have to trick it into working, like the above).

Node ESM specifically supports importing commonjs (I'm one of the authors).

Did you manage to use the various electron's objects in this solution? First I got an error (in main process):

(node:10468) UnhandledPromiseRejectionWarning: file:///D:/rep/Kogut/src/ElectronUI/main.js:10
import { app, dialog, ipcMain, screen } from 'electron';
         ^^^
SyntaxError: Named export 'app' not found. The requested module 'electron' is a CommonJS module, which may not support all module.exports as named exports.

then I tried to use import * as el from 'electron'; and accessing those with el.app, but it doesn't work in runtime:

(node:8336) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'on' of undefined
    at file:///D:/rep/Kogut/src/ElectronUI/main.js:337:8
    at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
    at async Loader.import (internal/modules/esm/loader.js:166:24)
    at async D:\rep\Kogut\src\ElectronUI\index.cjs:5:3

on a line calling el.app.on(...)

In some of the previous trials I had some success by using

declare var electron: any;
const { app, screen, ipcMain, dialog, BrowserWindow } = electron;
import * as el from 'electron';

(in Typescript) and then app.on seemed to work, while el.BrowserWindow could be used for type definitions, but with this recent solution this doesn't throw any error, but also doesn't properly execute the app callbacks.

Yes: destructure from the global electron prop (that was set in backend/index.cjs) instead of trying to import electron again:

// backend/app.js

import {
  pathToFile,
  URL,
} from 'url';


const { app, BrowserWindow } = electron;

app.whenReady().then(() => {
  const win = new BrowserWindow({โ€ฆ});
  const base = process.env.NODE_ENV = 'development'
    ? 'http://localhost:8080' // whatever webpack-dev-server is configured to
    : pathToFile(import.meta.url); // assumes index.html is in same folder as this file
  const docURL = new URL('index.html', base);

  win.loadUrl(docURL.toString());
});

So, is there any progress on ESM support in Electron? Do the maintainers even care? Please excuse me for being harsh in my choice of words, but that native modules will become the future is inevitable, and just keep ignoring (if not intentionally fighting) this current, in my humble opinion, will eventually, though indeed slowly, render Electron obsolete.

If, however, there are efforts into supporting ESM made by Electron, I think we deserve to know.

Based on @SmashingQuasar's post above, we were able to create a very simple workaround:

set "type": "module" in package.json, and

my-project/
  frontend/
    โ€ฆ
    index.html
  backend/
    index.cjs // very important to use `.cjs`
    app.js // regular ESM stuff
(async() => {
  global.electron = require('electron');
  await import('./app.js');
})();

It might be possible to simplify this further, but I haven't explored that yet. As-is, it seems to work just fine (and you can basically just forget about index.cjs).

Thanks for the working solution, @JakobJingleheimer!

The global assignment can be avoided by using node's createRequire so that you can require('electron') from the es module:

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const { app, BrowserWindow } = require('electron')

electron-webpack is broken in Electron 12+ due to the new contextIsolation: false default. So I ripped webpack out of my app and switched to raw TypeScript compilation. But I couldn't get CommonJS modules working on the renderer (main world) process due to nodeIntegration: false since require was no longer available. I looked it up and saw Electron 13 supports Node 14 so I should be able to just use ES modules for everything. Unfortunately only after trying that out did I find that it's broken in Electron for some reason, which brought me here.

The bit rot in the JS/Node ecosystem is getting a bit overwhelming. If it's hard to upgrade to a modern stack due to friction like ES modules not being supported, I imagine a lot of people will just give up and throw on contextIsolation: false and nodeIntegration: true as workarounds which will negate much of the advantage of these new secure defaults.

Right now the main solution I am considering is to emit CommonJS for the main and renderer (isolated world) processes and ES modules for the renderer (main world) process, requiring two separate tsc compilation steps.

awkj commented

This problem has been going on for two years, so why is Electron working tirelessly to upgrade Node and Chrome, but can't offer a solution, or even a temporary solution, to such an obvious shortcoming, and is V16 V15 V14 only useful for adding a few optional APIs?

@awkj I am not a member of Electron's team (so I can't speak for whatever is going on in their decision-making), but the extremely simple workaround above (and improved by @m59peacemaker) works perfectly fine. Yes, it's mildly frustrating (but I promise you I have already done most of the suffering for you in putting it together), but a very viable workaround exists and it has almost no impact whatsoever to the rest of an app's codebase: 1 set-and-forget file and a small addition to compilation config.

awkj commented

@awkj I am not a member of Electron's team (so I can't speak for whatever is going on in their decision-making), but the extremely simple workaround above (and improved by @m59peacemaker) works perfectly fine. Yes, it's mildly frustrating (but I promise you I have already done most of the suffering for you in putting it together), but a very viable workaround exists and it has almost no impact whatsoever to the rest of an app's codebase: 1 set-and-forget file and a small addition to compilation config.

Thank you for guiding this solution, maybe it's not Electron's fault, but seeing that the ESM of ES 2015 has not been solved until 2021, the front-end split in the basic ecological tools makes me feel more disappointed and powerless to change

Mm, I definitely felt your frustration.

Given the easy workaround though, I would actually rather the Electron team spend their efforts upgrading to new Node.js and Chroimium versions (and BUG FIXES!) since I can fix this myself, but I can't really fix the others.

Based on @SmashingQuasar's post above, we were able to create a very simple workaround:

set "type": "module" in package.json, and

my-project/
  frontend/
    โ€ฆ
    index.html
  backend/
    index.cjs // very important to use `.cjs`
    app.js // regular ESM stuff
(async() => {
  global.electron = require('electron');
  await import('./app.js');
})();

It might be possible to simplify this further, but I haven't explored that yet. As-is, it seems to work just fine (and you can basically just forget about index.cjs).

Hello there !
I'm trying to use this workaround but I have a problem.
I make my window with something like that in the app.js file :

const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

The app run but the the preload isn't loaded : "[ERR_REQUIRE_ESM]: Must use import to load ES Module".
It works if I set the proload as a cjs file but I loose the es module system...

Any ideas ?

@iRed4321 did you set "type": "module" in the package.json?

@iRed4321 did you set "type": "module" in the package.json?

Yes I just checked, and I did...

And even if my preload file I just put one console.log, without imports or anything, just the console.log, I still have the issue

Are you using WebPack? (Webpack's ESM support is broken)

Are you using WebPack? (Webpack's ESM support is broken)

No. Just theses:

const { app, BrowserWindow } = electron;
import setupPug from 'electron-pug';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const locals = {};

so nothing related I think

Oh! Sorry, you meant the preload in the config passed to BrowserWindow. Ahh, yes, Electron is probably trying to require() it under the hood, which is not allowed.

I don't have the Electron docs in front of me. Does it accept anything other than a filepath, or os there another config prop that does? If you can provide the preload code yourself, that should work.

Also, you're trying to use __dirname in that ESM; __dirname does not exist in ESM, so that should be erroring ๐Ÿค”

Oh! Sorry, you meant the preload in the config passed to BrowserWindow. Ahh, yes, Electron is probably trying to require() it under the hood, which is not allowed.

I don't have the Electron docs in front of me. Does it accept anything other than a filepath, or os there another config prop that does? If you can provide the preload code yourself, that should work.

Also, you're trying to use __dirname in that ESM; __dirname does not exist in ESM, so that should be erroring thinking

Yes __dirname don't exist, that's why I'm redefining it (after my import, you can see).
And my redefinition is good because it works if I pass a preload.cjs.

I don't know how electron is handling it and and I don't know how to define that preload code myself.
The fact is that i thought the workaroung would solve everything with a 100% no more issue but I was wrong !

@iRed4321 I looked through the Electron docs, and I don't see another way to do this via Electron itself. I'm not entirely sure from the docs' description what this does, but it sounds like it's essentially the same as putting a <script> tag at the beginning of your html doc. If not, to avoid spamming subscribers of this issue, please open a question in StackOverflow or the Node.js slack and tag me (I'm JakobJingleheimer in both).

The Missing part Security

I want to offer some context why Electron is not able to upgrade fast.

Electron has designed a API to create Apps with some security considerations in mind.
To Enforce that Security Model it was needed to Patch the Core v8 libs.

This patches can not be transported so easy.

If you do not care for situations where some one opens a link inside the electron app and directly can delete the whole PC it runs on you can use Something like NWJS. it gives you the full power of current node and chrome without the security features of electron.

For all others at point of time the workaround should be seen as standard way of doing it.

Importent where is the real ESM Incompat?

The Real incompat happens in the preload: './preload.cjs option on the created window this shared context is not using the nodejs context it reflects the node context to the browser context.

that means in details import() does not work it is a <script></script> context inside the browser that allows accessing nodejs's cjs context and it simply crashes as the browser has no import support inside the <script> context without usage of the module type and the changes electron did are not easy refactor able to use the ESM context directly and only as ESM gets executed async in the browser and CJS gets executed SYNC the only option to do something preload is inside the CJS context i can go even more deep but i think that explains it.

The good news is you do only need the preload script to register handlers that you can then use via ESM context of the browser again on the global window object

package.json

{
  "name": "electron-esm-starter",
  "version": "1.0.1",
  "description": "",
  "main": "electron-main.cjs",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "electron .",
    "debug": "electron --inspect-brk=5858 ."
  },
  "author": "Frank Lemanschik <frank@lemanschik.com>",
  "license": "Apache-2.0",
  "devDependencies": {
    "electron": "^13.1.7"
  }
}

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Hello World!</title>
  </head>
  <body>
    
    <h1>Hello World!</h1>
    We are using Node.js <span id="node-version"></span>,
    Chromium <span id="chrome-version"></span>,
    and Electron <span id="electron-version"></span>.

  </body>
</html>

electron-main.cjs

'use strict';

const electron = require('electron');
import('./electron-main.js').then((m) => m.load(electron));

electron-main.js ESM Context NodeJS

// You can use import with CJS and ESM
import { fileURLToPath } from 'url';

/**
 * This is equal to  as pathToFileURL(__dirname)
 * when called with import.meta.url as argument
 * @param {string} url
 * @returns
 */
const getBase = (url) =>
  url.substr(0, url.lastIndexOf('/') + 1);

export const load = async (electron) => {
  const { app, BrowserWindow, ipcMain } = electron;

  await app.whenReady();

  // All Electron Hooks only accept CJS as Electron uses require
  const preload = fileURLToPath(
    new URL('preload.cjs', getBase(import.meta.url))
  );

  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false, // is default value after Electron v5
      contextIsolation: true, // protect against prototype pollution
      enableRemoteModule: false, // turn off deprecated remote
      preload,
    },
  });

  // Register Backend Logic
  ipcMain.on('process.version', async (event, path) => {
    if (
      ['chrome', 'node', 'electron'].indexOf(path) === -1
    ) {
      return;
      //win.webContents.send('process.version', 'forbidden');
    }
    win.webContents.send('process.version', {
      [path]: process.versions[path],
    });
  });

  win.loadFile('index.html');

  // Only needed when your forced to use a dev or build server.
  //const docURL = new URL('index.html', getBase(import.meta.url)).toString();
  //win.loadURL(docURL);
};

preload.cjs

// import() does not work here require does
const { contextBridge, ipcRenderer } = require('electron');

const validChannels = [];
const addValidChannel = (channelOrCommand) =>
  validChannels.includes(channelOrCommand) ||
  validChannels.push(channelOrCommand);

const ipcClientWithValidation = {
  send(channelOrCommand, ...args) {
    if (validChannels.includes(channelOrCommand)) {
      ipcRenderer.send(channelOrCommand, ...args);
    }
  },
  on(channelOrCommand, func) {
    if (validChannels.includes(channelOrCommand)) {
      ipcRenderer.on(
        channelOrCommand,
        // strip event as it includes `sender`
        (event, ...args) => func(...args)
      );
    }
  },
};

const registerIpcRenderer = () => {
  contextBridge.exposeInMainWorld(
    'ipcRenderer',
    ipcClientWithValidation
  );
};

addValidChannel('process.version');
registerIpcRenderer();

// Access to Browser and Node is the first <script></script>
window.addEventListener('DOMContentLoaded', () => {
  // You can Enqueue ESM Scripts without NodeJS Context access
  const script = document.createElement('script');
  Object.assign(script, {
    type: 'module',
    src: './preload.js',
  });

  document.body.appendChild(script);
});

so preload is a own process that has the node and the window context and can only use CJS this example appends at the end of that script a preload.js with the ESM code of the App it self.

preload.js ESM running in the window context.

// Renderer / Browser Only ESM Context
const replaceText = (selector, text) => {
  const element = document.getElementById(selector);
  if (element) {
    element.innerText = text;
  }
};

const updateView = (data) => {
  for (const [name, version] of Object.entries(data)) {
    replaceText(`${name}-version`, version);
  }
};

// @ts-ignore
const { ipcRenderer } = window;
ipcRenderer.on('process.version', updateView);

for (const dependency of ['chrome', 'node', 'electron']) {
  ipcRenderer.send('process.version', dependency);
}

Goals

This is the default Secure way to use Electron you treat the ipcClient like a socket.io or similar client that sends messages to the server via a secure protocol.

this design pattern is not only secure it also enables to create a web version using socket.io or any other communication protocol via implementing the ipc handler interface of send and on which is similar to the eventEmitter pattern used by nodejs and streams with emit and on.

@JakobJingleheimer Thank you for your help and you're right I don't want to spam this issue. I think I'll find an other alternative, like NWJS maybe, but thank you.
Thank you for the context @frank-dspeed !

Kyza commented

Based on @SmashingQuasar's post above, we were able to create a very simple workaround:

set "type": "module" in package.json, and

my-project/
  frontend/
    โ€ฆ
    index.html
  backend/
    index.cjs // very important to use `.cjs`
    app.js // regular ESM stuff
(async() => {
  global.electron = require('electron');
  await import('./app.js');
})();

It might be possible to simplify this further, but I haven't explored that yet. As-is, it seems to work just fine (and you can basically just forget about index.cjs).

The main problem with this workaround is anything that is import()ed gets run after the Electron app is ready. Meaning everything that needs to be run before app.whenReady() must be CJS which quite annoying.

@Kyza did you read my comment it is the one over your and it shows how to execute esm before app is ready.

also it explains that the big problem is not the main script it is the preload script. (Mixed Context)