Webpackでsharpモジュールのビルドエラーを解決する方法

Webpackでビルドをしているのですが、sharpライブラリをinstallしたら突然Buildエラーになった。

ERROR in ./node_modules/sharp/build/Release/sharp-darwin-arm64v8.node 1:0
Module parse failed: Unexpected character '�' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)
 @ ./node_modules/sharp/build/Release/ sync ^\.\/sharp\-.*\.node$ ./sharp-darwin-arm64v8.node
 @ ./node_modules/sharp/lib/sharp.js 10:19-76
 @ ./node_modules/sharp/lib/input.js 8:14-32
 @ ./node_modules/sharp/lib/index.js 7:0-18
 @ ./src/utils.ts 21:30-46
 @ ./src/index.ts 13:14-32

webpack 5.88.2 compiled with 1 error and 1 warning in 19696 ms

原因は、Node.jsの画像処理ライブラリであるsharpをWebpackでビルドしようとした際に発生しています。 sharpは内部でネイティブのバイナリファイルを使用しており、Webpackはデフォルトではこれらのバイナリファイルを扱うことができません。

Webpackでsharpモジュールのビルドエラーを解決するために、 node-loaderを設定に追加する方法が効果的でした。 node-loaderはWebpackがネイティブの .nodeファイルを扱う際に必要なローダーです。 以下の設定をwebpack.config.jsに追加することで、問題を解決することができます:

Webpack

module: {
  rules: [
    {
      test: /\.node$/,
      use: 'node-loader',
    },
  ],
},

node-loaderはWebpackのローダーの一つで、 Node.jsのネイティブモジュール(.node拡張子を持つファイル)をWebpackで扱えるように変換する役割を持っています。 これにより、サーバーサイドやデスクトップアプリケーションでよく使用されるネイティブモジュールを、 Webアプリケーション内で利用することが可能になります。

環境設定

package.json

{
  "name": "sample",
  "version": "0.0.0",
  "description": "sample api",
  "main": "./src/index.ts",
  "scripts": {
    "local": "DEBUG=* nodemon ./src/index.ts",
    "build": "webpack --env NODE_OPTIONS=--openssl-legacy-provider --stats-error-details",
    "start": "DEBUG=* node ./dist/index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.409.0",
    "@aws-sdk/client-mediaconvert": "^3.409.0",
    "@aws-sdk/client-s3": "^3.409.0",
    "@aws-sdk/lib-storage": "^3.438.0",
    "@types/node": "^20.6.0",
    "amazon-cognito-identity-js": "^6.3.5",
    "axios": "^1.5.0",
    "body-parser": "^1.20.2",
    "cdate": "^0.0.7",
    "co": "^4.6.0",
    "cors": "^2.8.5",
    "debug": "^4.3.4",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "file-type": "^18.5.0",
    "inversify": "^6.0.1",
    "inversify-binding-decorators": "^4.0.0",
    "inversify-express-utils": "^6.4.3",
    "ioredis": "^5.3.2",
    "jmespath": "^0.16.0",
    "jose": "^4.15.1",
    "json2csv": "^6.0.0-alpha.2",
    "jwk-to-pem": "^2.0.5",
    "make-error": "^1.3.6",
    "mysql2": "^3.6.0",
    "node-fetch": "^3.3.2",
    "reflect-metadata": "^0.1.13",
    "sequelize": "^6.33.0",
    "sequelize-cli": "^6.6.1",
    "sharp": "^0.32.6",
    "ts-jose": "^4.15.1",
    "uuid": "^9.0.0"
  },
  "devDependencies": {
    "@babel/plugin-proposal-decorators": "^7.22.15",
    "@babel/preset-env": "^7.22.15",
    "@babel/preset-typescript": "^7.22.15",
    "@types/cors": "^2.8.15",
    "@types/express": "^4.17.17",
    "@types/jsonwebtoken": "^9.0.3",
    "@types/jwk-to-pem": "^2.0.1",
    "@types/redis": "^4.0.11",
    "@typescript-eslint/eslint-plugin": "^6.6.0",
    "aws-crt": "^1.18.0",
    "babel-loader": "^9.1.3",
    "eslint": "^8.49.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-config-standard-with-typescript": "^39.0.0",
    "eslint-plugin-import": "^2.28.1",
    "eslint-plugin-jest": "^27.6.0",
    "eslint-plugin-n": "^16.0.2",
    "eslint-plugin-promise": "^6.1.1",
    "jest": "^29.6.4",
    "node-loader": "^2.0.0",
    "nodemon": "^3.0.1",
    "pg-hstore": "^2.3.4",
    "prettier": "^3.0.3",
    "terser-webpack-plugin": "^5.3.9",
    "ts-loader": "^9.4.4",
    "ts-node": "^10.9.1",
    "tsconfig-paths-webpack-plugin": "^4.1.0",
    "typescript": "^5.2.2",
    "webpack": "^5.88.2",
    "webpack-cli": "^5.1.4",
    "webpack-node-externals": "^3.0.0",
    "zod": "^3.22.2"
  },
  "nodemonConfig": {
    "watch": [
      "src"
    ],
    "ext": "ts",
    "exec": "node --inspect --require ts-node/register ./src/index.ts"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "sourceMap": true,
    "target": "esnext",
    "module": "CommonJS",
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "./",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "noImplicitAny": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "useDefineForClassFields": true,
    "strictPropertyInitialization": false,
    "typeRoots": ["./types", "./node_modules/@types"]
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  },
  "include": ["src/**/*"]
}

webpack.config.js

const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'production', //development | production
  target: 'node',
  externals: [nodeExternals()], // ネイティブモジュールを外部依存関係として扱う
  entry: './src/index.ts',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'dist'),
    libraryTarget: 'commonjs2',
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.ts$/,
        include: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'test')],
        exclude: /(node_modules | test)/,
        loader: 'babel-loader',
        options: {
          babelrc: false,
          presets: ['@babel/preset-env', ['@babel/preset-typescript', { allownamespaces: true }]],
          plugins: [['@babel/plugin-proposal-decorators', { version: '2023-05' }]],
        },
      },
      {
        test: /\.ts$/,
        include: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'test')],
        exclude: /(node_modules | test)/,
        use: [{ loader: 'ts-loader' }],
      },
      {
        test: /\.node$/,
        use: 'node-loader',
      },
    ],
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,
          },
          keep_classnames: true,
          keep_fnames: true,
          sourceMap: true,
        },
        parallel: true,
      }),
    ],
  },
  resolve: {
    extensions: ['.ts', '...'],
    alias: {
      'aws-crt': path.resolve(__dirname, 'node_modules/aws-crt'),
    },
    plugins: [new TsconfigPathsPlugin()],
  },
  ignoreWarnings: [
    { module: /aws-crt/ },
    { module: /express/ },
    { module: /sequelize/ },
    { module: /express/ },
    {
      message: /Critical dependency: the request of a dependency is an expression/,
    },
  ],
};