Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for native .node modules #1051

Closed
Kinrany opened this issue Mar 25, 2021 · 24 comments
Closed

Support for native .node modules #1051

Kinrany opened this issue Mar 25, 2021 · 24 comments

Comments

@Kinrany
Copy link

Kinrany commented Mar 25, 2021

I'm trying to use esbuild to minify my Node project, to make my container images smaller with a multi-stage build.

I need lovell/sharp, which has a .node module. This breaks the build.

I could mark that module as external. But I'm also using pnpm, so the package and its dependencies are actually behind symlinks. It seems I'd have to manually move modules and replace paths in esbuild output to make this work.

Ideally esbuild would assume that native modules have no other dependencies and just place them next to the regular output.

@evanw
Copy link
Owner

evanw commented Mar 25, 2021

You could argue that esbuild should handle this itself. However, it currently doesn't do this. The file loader almost does this but it returns a path to the file instead of loading the file. But you can use the file loader in a small plugin to do what you want without needing this feature to be built into esbuild:

const nativeNodeModulesPlugin = {
  name: 'native-node-modules',
  setup(build) {
    // If a ".node" file is imported within a module in the "file" namespace, resolve 
    // it to an absolute path and put it into the "node-file" virtual namespace.
    build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({
      path: require.resolve(args.path, { paths: [args.resolveDir] }),
      namespace: 'node-file',
    }))

    // Files in the "node-file" virtual namespace call "require()" on the
    // path from esbuild of the ".node" file in the output directory.
    build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({
      contents: `
        import path from ${JSON.stringify(args.path)}
        try { module.exports = require(path) }
        catch {}
      `,
    }))

    // If a ".node" file is imported within a module in the "node-file" namespace, put
    // it in the "file" namespace where esbuild's default loading behavior will handle
    // it. It is already an absolute path since we resolved it to one above.
    build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({
      path: args.path,
      namespace: 'file',
    }))

    // Tell esbuild's default loading behavior to use the "file" loader for
    // these ".node" files.
    let opts = build.initialOptions
    opts.loader = opts.loader || {}
    opts.loader['.node'] = 'file'
  },
}

@evanw evanw changed the title Minification and native modules Support for native .node modules Mar 25, 2021
Kinrany added a commit to Kinrany/demo-typescript-service that referenced this issue Mar 25, 2021
Kinrany added a commit to Kinrany/demo-typescript-service that referenced this issue Mar 25, 2021
Squashed commit of the following:

commit 496340f43970424f81cdcfef3430b119e15fbb0f
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 17:11:23 2021 +0300

    Push persistence to container registry

commit b65aea0
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 17:05:09 2021 +0300

    Split build for persistence into two stages

commit e5b8cfa
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 16:05:22 2021 +0300

    Revert building worker with esbuild for now

    See evanw/esbuild#1051

commit 2812a18
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 03:26:34 2021 +0300

    Fix

commit 78e81ad
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 02:09:07 2021 +0300

    Try running "prisma generate" explicitly

commit 09d7b99
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 02:05:34 2021 +0300

    Rewrite persistence service with esbuild

commit c2fb6a2
Author: Ruslan Fadeev <kinrany@yandex.com>
Date:   Thu Mar 25 01:36:10 2021 +0300

    Do not push to container registry for now
@evanw
Copy link
Owner

evanw commented Mar 25, 2021

I tested the above plugin with the fsevents module and it worked for that. So the plugin does work for some packages with .node files.

But from the discussion in #972, it sounds like lovell/sharp won't work even if .node files are copied over because that package also needs to load other random files from the file system as determined by run-time environment variables, which cannot be bundled ahead of time (i.e. the sharp package is incompatible with bundling).

So the solution here is to mark this module as external with --external:sharp and make sure the sharp package is still installed at run-time when you run your bundle.

@Kinrany
Copy link
Author

Kinrany commented Mar 26, 2021

Would it make sense for esbuild to copy packages that shouldn't be bundled into a new node_modules in the output directory?

Perhaps even minify each package, but keep the separation between packages.

The problem I'm still having in my case is that node_modules only contains symlinks, and I'd have to resolve all of those to make sure all necessary code is inside outdir.

@deadcoder0904
Copy link

@evanw Would love a solution to this as well. I'm using mdx-bundler which uses esbuild underhood. I run sharp to convert gif files to png & move them to the public/ folder.

So tried creating a plugin, see kentcdodds/mdx-bundler#74

But it yells at using .node

@fr-an-k
Copy link

fr-an-k commented Dec 1, 2021

+1

@iammati
Copy link

iammati commented Jan 6, 2022

Noticed that one of my dependencies (limax - https://github.com/lovell/limax) also uses .node but it isn't supported by esbuild yet. Is there at least a workaround for this? Development is a struggle without a dev-server 🤣

EDIT: welp, a workaround (at least in my case) was easier than I thought (using vitejs!):

import { UserConfig, defineConfig } from 'vite'

const configuration: UserConfig = {
    ...
    optimizeDeps: {
        exclude: [
            'limax',
        ],
    },
}

export default defineConfig(configuration)

Excluding the package/dependency which has a .node file and throws something like error: No loader is configured for ".node" files: simply add the snippet with the optimizeDeps.exclude inside your vite.config.(js|ts) file and you should be able to use the dev-server again.

Also see: https://vitejs.dev/config/#optimizedeps-exclude

I hope for anyone who stumbles over this issue can make an use of this (:

@jeffgunderson
Copy link

jeffgunderson commented Jan 11, 2022

I get this same thing in a Jenkins environment with ssh2 library using serverless framework + serverless-esbuild plugin. I fixed it by adding it to the external list

esbuild: {
    plugins: 'esbuild.plugins.js',
    external: ['pg-native', 'ssh2'],
},

@spion
Copy link

spion commented Feb 28, 2022

Would it be okay if we added additional options to the onLoad / onResolve callback, specifying additional files to include? That way onResolve could return any additional .dll / .so / .other` files that would need to be copied.

@shivangsanghi
Copy link

I

I am getting error: Cannot use the "file" loader without an output path

@evanw
Copy link
Owner

evanw commented Dec 11, 2022

Native node modules could very well depend on the location of files in the file system. In that case no plugin is going to work. You'll have to mark the package as external to exclude it from the bundle, and make sure the files are on the file system in the right places at run-time when the bundle is evaluated.

ntegan1 added a commit to ntegan1/opwebapp that referenced this issue Dec 15, 2022
this doesnt work because esbuild doesnt know
what to do with .node files. (ssh2 and cpu-something)

evanw/esbuild#1051

or switch to js instead of ts see if helps
or just replace .node files with something?
@LinirZamir
Copy link

Hey did you figure out a solution to this issue? I am having the same problem

@dallen4
Copy link

dallen4 commented Jan 18, 2023

@LinirZamir the --loader flag used in this comment works for me.

@oleksandr-danylchenko
Copy link

@LinirZamir the --loader flag used in this comment works for me.

What comment?

@Aarbel
Copy link

Aarbel commented Apr 19, 2023

On my side using --loader:.node=file solved the problem, but it was hard to set because i thought it was applied to another deploy / environnement.

On AWS cdk i did that:

          bundling: {
            externalModules: props.nodeModules.dependencies,
            loader: {
              ".node": "file",
            },
            minify: true,
            sourceMap: true,
          },

@felixebert
Copy link

felixebert commented Oct 11, 2023

For those finding this issue when trying to use sharp in a NodeJS Lambda Function (like me):

I can successfully use sharp in a lambda nodejs CDK function using esbuild bundling (not docker bundling & no lambda layer) with the following options. Also works in a Github Action workflow.

(Note: Use --arch=arm64 for CDK lambda.Architecture.ARM_64

new lambdaNodejs.NodejsFunction(this, "SOME_ID", {
  // ...
  bundling: {
    externalModules: ["sharp"],
    nodeModules: ["sharp"],
    commandHooks: {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      beforeBundling(inputDir: string, outputDir: string): string[] {
        return [];
      },
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      beforeInstall(inputDir: string, outputDir: string): string[] {
        return [];
      },
      afterBundling(inputDir: string, outputDir: string): string[] {
        return [`cd ${outputDir}`, "rm -rf node_modules/sharp && npm install --no-save --arch=x86 --platform=linux sharp"];
      }
    }
  }
  // ...
});

Taken from aws-solutions/serverless-image-handler

@robertsLando
Copy link

Any clue how I can make this work with serialport?

@nopol10
Copy link

nopol10 commented Mar 26, 2024

For those using serverless-esbuild and encountering this with ssh2 (or probably other packages), instead of adding ssh2 to external, you can add the loader option to esbuild's settings like this:

serverless.yml:

esbuild:
  ...
  loader:
    ".node": "file"

This will cause the sshcrypto.node file to be copied to the same folder as the js file generated by esbuild and the resulting js should point to the copied file's path correctly.

This is if you don't have ssh2 in the lambda's environment (which some people might have in a layer).

@artiz
Copy link

artiz commented May 24, 2024

This config works for me:

/* eslint-disable */
/* tslint:disable */
const { build } = require("esbuild");
const path = require("path");
const fs = require("fs");


const findBinaryFiles = (dir) => {
   const binaries = [];
   const files = fs.readdirSync(dir);
   for (const file of files) {
      const filePath = path.join(dir, file);
      const stat = fs.statSync(filePath);
      if (stat.isDirectory()) {
         binaries.push(...findBinaryFiles(filePath));
      } else if (path.extname(file) === ".node") {
         binaries.push(filePath);
      }
   }
   return binaries;
};


const nativeNodeModulesPlugin = {
   name: "native-node-modules",
   setup(build) {
      const baseOutdir = build.initialOptions.outdir || path.dirname(build.initialOptions.outfile);
      const outdir = path.resolve(baseOutdir);
      const buildDir = path.join(outdir, 'build');
      
      if (!fs.existsSync(outdir)) fs.mkdirSync(outdir);
      if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir);

      const processedBinaries = new Set();
      
      build.onResolve({ filter: /bindings/, namespace: "file" }, (args) => {
         const filePath =  require.resolve(args.path, { paths: [args.resolveDir] });
         const { resolveDir } = args;
         let packageDir = path.dirname(resolveDir);
         while(packageDir && path.basename(packageDir) !== "node_modules") {
            packageDir = path.dirname(packageDir);
         }
         packageDir = path.dirname(packageDir);

         // find '.node' files in the packageDir
         const binaries = findBinaryFiles(packageDir);
         binaries.forEach((binary) => {
            const fname = path.basename(binary);
            if (!processedBinaries.has(fname)) {
               const outPath = path.join(buildDir, fname);
               fs.copyFileSync(binary, outPath);
               processedBinaries.add(fname);
            }
         });
         
         return {
            path: filePath,
            namespace: "bindings",
         };
      });

      build.onLoad({ filter: /.*/, namespace: "bindings" }, (args) => {
         return {
            contents: `
            const path = require("path");
            const fs = require("fs");
            const __bindings = require(${JSON.stringify(args.path)});

            module.exports = function(opts) {
               if (typeof opts == "string") {
                  opts = { bindings: opts };
               } else if (!opts) {
                  opts = {};
               }

               opts.module_root = path.dirname(__filename);
               return __bindings(opts);
            };
          `,
         };
      });

      build.onResolve({ filter: /bindings\.js$/, namespace: "bindings" }, (args) => {
         return {
            path: args.path,
            namespace: "file",
         };
      });
   },
};

const options = {
   entryPoints: ["src/index.ts"],
   outfile: "deploy/index.js",
   bundle: true,
   minify: false,
   sourcemap: true,
   external: ["aws-sdk"],
   platform: "node",
   loader: {
      ".svg": "file",
      ".html": "text",
   },
   define: {},
   plugins: [nativeNodeModulesPlugin],
};

build(options).catch(() => process.exit(1));
 

@justinwaite
Copy link

justinwaite commented Jun 20, 2024

For those using serverless-esbuild and encountering this with ssh2 (or probably other packages), instead of adding ssh2 to external, you can add the loader option to esbuild's settings like this:

serverless.yml:

esbuild:
  ...
  loader:
    ".node": "file"

This will cause the sshcrypto.node file to be copied to the same folder as the js file generated by esbuild and the resulting js should point to the copied file's path correctly.

This is if you don't have ssh2 in the lambda's environment (which some people might have in a layer).

Worked for me when deploying an SST Ion Remix app with the react-pdf package.

    new sst.aws.Remix('MyApp', {
      transform: {
        server: {
          nodejs: {
            esbuild: {
              loader: {
                '.node': 'file',
              },
            },
          },
        },
      },
    });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests