Skip to content

Allow ESM modules imported in render process to be found in node_modules #46448

@gpetrov

Description

@gpetrov

Preflight Checklist

Problem Description

Currently any ESM module imports in the renderer process have to be either bundled or locally available to be loaded.

It will be very useful if the chrome loader can be patched to search them also in the node_modules, so there will be no need for bundling.

@MarshallOfSound

Proposed Solution

As a temporally solution, it is possible to hook a custom protocol in the main process to that will search and serve the es6 modules. But this should be actually integrated in Electron. Example:

Usage:
import some_module from 'es6://loader/some_module'

// Register custom protocol for ES6 modules
  protocol.handle('es6', async (request) => {
    try {
      const urlObj = new URL(request.url);
      let modulePath = urlObj.pathname;

      // Decode URI components to get proper file path
      modulePath = decodeURIComponent(modulePath);

      // Check if the path starts with a slash and remove it
      if (modulePath.startsWith('/')) {
        modulePath = modulePath.substring(1);
      }

      // Parse the package name and subpath
      let packageName, subPath;

      // Handle scoped packages (@org/pkg)
      if (modulePath.startsWith('@')) {
        const parts = modulePath.split('/');
        // Scoped package name is @org/pkg
        packageName = parts.slice(0, 2).join('/');
        // Subpath is everything after the package name
        subPath = parts.slice(2).join('/');
      } else {
        // Regular package
        const parts = modulePath.split('/');
        // Package name is the first part
        packageName = parts[0];
        // Subpath is everything after the package name
        subPath = parts.slice(1).join('/');
      }

      // Find the package in node_modules
      const basePath = path.join(app.getAppPath(), 'node_modules', packageName);

      // Read package.json to find exports mapping
      const packageJsonPath = path.join(basePath, 'package.json');
      if (!fs.existsSync(packageJsonPath)) {
        throw new Error(`Package not found: ${packageName}`);
      }

      const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));

      // Check if the package has exports field
      if (packageJson.exports) {
        let exportPath;

        if (subPath.endsWith('.js')) {
          // If the subPath ends with .js, we need to handle it differently
          exportPath = subPath;
        } else {
          if (subPath === '') {
            // Main export
            exportPath = typeof packageJson.exports === 'object'
              ? (packageJson.exports['.']?.import || packageJson.exports['.']?.browser || packageJson.exports['.']?.default || packageJson.exports['.'] || packageJson.exports['import'])
              : packageJson.exports; // Handle case where exports is a string
          } else {
            // Subpath export
            const exportKey = './' + subPath;
            exportPath = packageJson.exports[exportKey]?.import || packageJson.exports[exportKey]?.browser || packageJson.exports[exportKey]?.default || packageJson.exports[exportKey];
          }
        }

        if (exportPath) {
          const filePath = path.join(basePath, exportPath);

          if (fs.existsSync(filePath)) {
            const fileContent = fs.readFileSync(filePath, 'utf8');

            //fix all imports to start with es6://
            const fileContentStr = fileContent.toString()
            const modifiedContents = fileContentStr.replace(/(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/g, (match, p1) => {
              // if it starts with . then prepend the current module path
              if (p1.startsWith('./')) {
                match = match.replace(p1, 'es6://loader/' + packageName + subPath + p1.replace(/^\.\//, '/'))
                return match;
              } else if (p1.startsWith('/')) {
                return match; // Don't modify the import statement
              } else if (p1.startsWith('node:')) {
                // generate own node import so they work
               //todo
              } else {
                // Prepend 'es6://' to the import path
                return match.replace(p1, `es6://loader/${p1}`);
              }
            })
            // Return the file content with proper MIME type
            return new Response(modifiedContents, {
              headers: {
                'Content-Type': 'application/javascript; charset=utf-8'
              }
            });
          } else {
            throw new Error(`File not found: ${filePath}`);
          }
        }
      }

      // Fallback to direct resolution for packages without exports field
      const directPath = path.join(basePath, subPath || 'index.js');
      if (fs.existsSync(directPath)) {
        const fileContent = fs.readFileSync(directPath, 'utf8');
        return new Response(fileContent, {
          headers: {
            'Content-Type': 'application/javascript; charset=utf-8'
          }
        });
      }

      throw new Error(`Could not resolve module: ${packageName}/${subPath}`);
    } catch (error) {
      console.error('ES6 protocol error:', error);
      return new Response(`console.error('Failed to load ES6 module: ${error.message}')`, {
        status: 404,
        headers: {
          'Content-Type': 'application/javascript; charset=utf-8'
        }
      });
    }
  });

However the chrome loader, while it loads all successfully, throws an error about the invalid protocol es6://

So maybe the protocol isn't fully registered by Electron also for Chrome imports?

Alternatives Considered

This should be integrated in Electron and offered as native solution.

Same is also valid for loading node modules in the rendered process if allowed.

Maybe this can be also solved with a custom protocol:

  protocol.handle('node', async (request) => {
    try {
      const requestPath = new URL(request.url).pathname;
      const moduleName = requestPath.startsWith('/') ? requestPath.substring(1) : requestPath;

      // Handle built-in Node.js modules
      if (moduleName) {

        // For requiring the actual module, we need to strip the "node:" prefix
        const actualModuleName = moduleName.replace(/^node:/, '');

        // Create module wrapper for ES module compatibility
        const moduleContent = `
          const nodeModule = require('${actualModuleName}');
          export default nodeModule;
          // Export all properties of the module
          for (const key in nodeModule) {
            if (Object.prototype.hasOwnProperty.call(nodeModule, key)) {
              export const \${key} = nodeModule[key];
            }
          }
        `;

        console.log('Node[' + moduleName + '] module content:', moduleContent);

        return new Response(moduleContent, {
          headers: {
            'Content-Type': 'application/javascript'
          }
        });
      }

      throw new Error(`Could not resolve Node.js module: ${moduleName}`);
    } catch (error) {
      console.error('Error handling node: protocol:', error);
      return new Response(`console.error("Failed to load Node.js module: ${error.message}");`, {
        status: 500,
        headers: {
          'Content-Type': 'application/javascript'
        }
      });
    }
  });

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions