microsoft / node-api-dotnet

Advanced interoperability between .NET and JavaScript in the same process.
MIT License
518 stars 56 forks source link

A possible vite plugin to make this work with electron. #397

Open reitowo opened 3 days ago

reitowo commented 3 days ago

After days of research I wrote a plugin to make this cool node-api-dotnet works with electron. Hope it can help people later.

  1. Create a vite plugin
import fs from "fs";
import path from "path";

interface ElectronNodeApiDotNetOptions {
    // The path to `node_modules/node-api-dotnet
    nodeModulePath: string,

    // The path to all generated files and assemblies
    csprojOutputPath: string,
}

export default (options: ElectronNodeApiDotNetOptions) => {
    const name = "electron-node-api-dotnet";

    let assemblyName = "Microsoft.JavaScript.NodeApi";
    let assemblyRoot = path.normalize(options.csprojOutputPath)

    const transformInitJs = (code: string) => {

        // Create require to dynamic require .node files.
        code = `
                    import path from "path";
                    import { createRequire } from 'module';
                    const require = createRequire(import.meta.url);
                    const bundleRoot = ${JSON.stringify(options.nodeModulePath)};
                ` + code

        // Since we use import above, we also need to replace the export style.
        // https://github.com/rollup/plugins/issues/1121
        code = code.replace(
            "module.exports = initialize",
            "export default initialize"
        )

        // Get the dotnet assembly name, preventing upstream change.
        const regex = /const assemblyName\s*=\s*['"`](.*?)['"`];/;
        const match = code.match(regex);
        if (match) {
            assemblyName = match[1];
            console.log(`[${name}] Using assembly name: ${assemblyName}`);
        } else {
            console.error(
                `[${name}] Cannot match assembly name, plugin may not work!`
            );
        }

        // Transform dynamic assemblyName require to a static one
        // Also transform the relative paths to be prefixed with provided bundle root.
        code = code.replace(
            /require\(`\.\/(.+?)\/\$\{assemblyName\}\.node`\)/g,
            "require(path.join(bundleRoot, `./$1/" + assemblyName + ".node`))"
        );

        // Transform the default case.
        code = code.replace(
            "nativeHost = require(__dirname + `/${rid}/${assemblyName}.node`)",
            "nativeHost = require(path.join(bundleRoot, `./${rid}/${assemblyName}.node`))"
        )

        // Transform the path for managed host.
        code = code.replace(
            "const managedHostPath = __dirname + `/${targetFramework}/${assemblyName}.DotNetHost.dll`",
            "const managedHostPath = path.join(bundleRoot, `./${targetFramework}/${assemblyName}.DotNetHost.dll`)"
        )

        return code;
    }

    const transformAssemblyJs = (code: string, filePath: string) => {

        // Instead of use import.meta.url
        code = code.replace(
            "import { fileURLToPath } from 'node:url';", ""
        )

        // Use the absolute path, because these files will be rollup 
        // to one single file (for example main.js)
        code = code.replace(
            "const __filename = fileURLToPath(import.meta.url)",
            `const __filename = ${JSON.stringify(filePath)}`
        )

        return code
    }

    return {
        name,
        resolveId(source: string, importer: string | undefined, options: any) {
            return null;
        },
        transform(code: string, id: string) {
            const normId = path.normalize(id)
            if (id.endsWith("node_modules/node-api-dotnet/init.js")) {
                return transformInitJs(code)
            } else if (normId.startsWith(assemblyRoot)) {
                return transformAssemblyJs(code, normId)
            }
            return code;
        },
        load(id: string) {
            if (id.endsWith(`${assemblyName}.node`)) {
                // Treat .node files as empty string, otherwise there'll be error because binary are
                // not valid js files.
                return ``;
            }
            return null;
        },
        generateBundle(options: any, bundle: any) {
            // bundle whatever way you want
        },
    };
};
  1. Include it in vite config, or nuxt config, if you like. Remember to exclude files from commonjs plugin.
    
    import commonjs from '@rollup/plugin-commonjs'
    import resolve from '@rollup/plugin-node-resolve'
    import electronNodeDotNetApi from './rollup/electron-node-api-dotnet'

...

vite: { plugins: [ resolve(), commonjs({ exclude: [ "node_modules/node-api-dotnet/**" ] }), electronNodeDotNetApi({ nodeModulePath: path.join(projectRootDir, 'node_modules/node-api-dotnet'), csprojOutputPath: csharpOutputDir }), ] }

reitowo commented 2 days ago

The changes mainly are, replacing relative path to absolute paths.

I think we can also first require.resolve the 'node-api-dotnet', and resolve the absolute path, for better compatibility.

Also, replace require with createRequire is also necessary.