モードの変更

gulpfile.esm.js で gulp-imagemin v8.0.0 を利用するとエラーになる

gulp
JavaScript
Node.js

Node.js は v12 以降なら ESM が使えるが、gulp v4 もルートタスクファイルの名前を gulpfile.esm.js とし、devDependencies に esm モジュールを追加する(npm i -D esm)ことで、gulp タスクで ESM の import/export 構文が使えるようになる。

Transpilation - JavaScript and Gulpfiles | gulp

これで意気揚々と gulp-imagemin v8.0.0 を import するとエラーになる。

この記事は「gulp で ESM を使いたいが gulp-imagemin v8.0.0 でエラーが出た人」をターゲットに書いており、「gulp は CommonJS 構文のままで gulp-imagmin を v8.0.0 にあげたらエラーが出たが、gulp を ESM にしたくない人」向けの内容ではない。

後者の人への解決策としては、「gulp-imagemin v7.1.0 を使う」以外はないと思われる。

アウトライン

解決方法

3 つある。繰り返すが「gulp を ESM で利用したい、かつ gulp-imagemin を利用したい」人向けの解決策である。

1: gulp-imagemin を v7.1.0 にダウングレードする

gulp-imagemin を v7.1.0 に下げれば、gulpfile.esm.js で import/export 構文のままエラーなく動作するようになる。

2: package.json に "type": "module" を追加する

package.json に "type": "module" を追加すると、esm モジュールなしで import/export が使えて gulp-imagemin も v8.0.0 が動く。gulpfie.esm.js へのリネームも不要だ。

なお、相対パスで import/export する場合は拡張子を省略することはできない。

// guilfile.js
import gulp from "gulp"; // `node_modules/` からは拡張子は省略可能
import { image } from "./task/image.js"; // 相対パスなので拡張子は省略不可

export default gulp.series(image);

注意点として、package.json に "type": "module" があるとプロジェクト内で Node.js で実行されるファイル全てに ESM を要求するので、gulp タスク以外も ESM 化しなくてはならない。

3: タスクファイルの拡張子を全て .mjs にする

Node.js は拡張子が .mjsのファイルを ESM で実行するようになっている。

なので、gulpfile.mjs にリネームすることで、gulp-imagemin v8.0.0 でも動くようになる。esm モジュールは不要だ。タスクファイルを分割している場合、例えば画像処理に関するタスクファイルを task/image.js としたら、そちらも task/image.mjs とリネームしよう。

こちらも相対パスでの import/export の拡張子は省略できないが、"type": "module" と違って CommonJS のまま使いたいスクリプトは .js のままで動作する。

No gulpfile found エラーが出たら

gulp v4 に同梱されている gulp-cli が v2.3.0 になっていない可能性がある。

npx gulp
[00:00:00] No gulpfile found

npx gulp -v
CLI version: 2.2.0 // <= bad
Local version: 4.0.2

その場合は package-lock.json を削除してから npm i し直すとよい。

rm -f package-lock.json
npm i
npx gulp -v

CLI version: 2.3.0 // <= good
Local version: 4.0.2

gulp-cli が .mjs で ESM をサポートしたのは v2.3.0 からなので注意だ。

Release v2.3.0 · gulpjs/gulp-cli

比較

解決方法を一覧してみた。

gulp タスクを ESM 化するのでどの手段においても既存タスクファイルの require()import hoge from "hoge" に変更する必要があるし、module.exportsexport に変更する必要がある。

方法 必須作業 備考
gulp-imagemin v7.1.0 を使う npm i -D esmgulpfile.esm.js へのリネーム ESM 化はできるが dependabot に警告されても gulp-imagemin を更新できない
package.json に "type": "module" を追加する package.json へのオプション追加 gulpfile.js のままで gulp-imagemin も v8.0.0 が使えるが gulp タスク以外の Node.js 互換スクリプトも ESM 化する必要がある
タスクファイルの拡張子を全て .mjs にする 対象ファイルの拡張子の変更 先進的。CommonJS のまま使いたい Node.js 互換スクリプトは変更しなくてよい

それぞれのケースをまとめたリポジトリを作った

これを誰でも再現できるように、エラーが出る環境と、3 つの解決方法を全てまとめたリポジトリを作ってみた。各ディレクトリで npm ci が必要だが、手元で比べられるので参考にされたい。

gulp-imagemin-with-esm

エラーの再現手順とエラーログ

次のコマンドを一つずつ行なっていくとあなたにもエラーログが手に入る。

# お好みのディレクトリでどうぞ
mkdir awesome-project
cd awesome-project
npm init -y
touch gulpfile.esm.js
mkdir task
touch ./task/image.js

# 必要モジュールをインストールする
npm i -D esm gulp gulp-imagemin

package.json の devDependencis フィールドは次のようになっているだろう。

// package.json
{
  ...
  "devDependencies": {
      "esm": "^3.2.25",
    "gulp": "^4.0.2",
    "gulp-imagemin": "^8.0.0"
  },
  ...
}

このうえで gulpfile.esm.js を次のように、

// guilfile.esm.js, edit like below
import gulp from "gulp";
import { image } from "./task/image";

export default gulp.series(image);

./task/image.js を次のようにする。

// ./task/image.js, edit like below
import gulp from "gulp";
import imagemin, { mozjpeg } from "gulp-imagemin";

export const image = () =>
  gulp
    .src("./src/image/**/*")
    .pipe(imagemin([mozjpeg({ quality: 50 })]))
    .pipe(gulp.dest("./dist/image/"));

サンプルということでシンプルにしている。これが!シンプルな!サンプル!

ということでプロジェクトルートで

npx gulp

を実行すると、

[01:23:45] Requiring external module esm
TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]: A dynamic import callback was not specified.
    at new NodeError (node:internal/errors:371:5)
    at importModuleDynamicallyCallback (node:internal/process/esm_loader:39:9)
    at eval (eval at <anonymous> (/Users/awesome-project/node_modules/gulp-cli/lib/shared/require-or-import.js:10:15), <anonymous>:3:1)
    at requireOrImport (/Users/awesome-project/node_modules/gulp-cli/lib/shared/require-or-import.js:24:7)
    at execute (/Users/awesome-project/node_modules/gulp-cli/lib/versioned/^4.0.0/index.js:37:3)
    at Liftoff.handleArguments (/Users/awesome-project/node_modules/gulp-cli/index.js:211:24)
    at Liftoff.execute (/Users/awesome-project/node_modules/liftoff/index.js:201:12)
    at module.exports (/Users/awesome-project/node_modules/flagged-respawn/index.js:51:3)
    at Liftoff.<anonymous> (/Users/awesome-project/node_modules/liftoff/index.js:191:5)
    at /Users/awesome-project/node_modules/liftoff/index.js:149:9 {
  code: 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING'
}

TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]: A dynamic import callback was not specified. エラーが出る。

さらにもう一度 npx gulp を実行すると、

[02:34:56] Requiring external module esm
TypeError: Invalid host defined options
    at eval (eval at <anonymous> (/Users/awesome-project/node_modules/gulp/node_modules/gulp-cli/lib/shared/require-or-import.js:10:15), <anonymous>:3:1)
    at requireOrImport (/Users/awesome-project/node_modules/gulp/node_modules/gulp-cli/lib/shared/require-or-import.js:24:7)
    at execute (/Users/awesome-project/node_modules/gulp/node_modules/gulp-cli/lib/versioned/^4.0.0/index.js:37:3)
    at Liftoff.handleArguments (/Users/awesome-project/node_modules/gulp/node_modules/gulp-cli/index.js:211:24)
    at Liftoff.execute (/Users/awesome-project/node_modules/liftoff/index.js:201:12)
    at module.exports (/Users/awesome-project/node_modules/flagged-respawn/index.js:51:3)
    at Liftoff.<anonymous> (/Users/awesome-project/node_modules/liftoff/index.js:191:5)
    at /Users/awesome-project/node_modules/liftoff/index.js:149:9
    at /Users/awesome-project/node_modules/v8flags/index.js:162:14
    at /Users/awesome-project/node_modules/v8flags/index.js:41:14

TypeError: Invalid host defined options エラーとなり、以降はずっと同じエラーになる。

Requiring external module esm 部分は正しく動作する場合でも表示されるので気にしなくてよい。

エラーログのスタックトレースを見てもタスクファイルのどこが悪いのかわからないが、手元のプロジェクトで色々検証した結果 gulp-imagemin v8.0.0 が原因だった。この構成で同じエラーが出るので間違いないだろう。

どうしてこんなエラーが出るのか

gulp-imagemin のリポジトリのイシューで「TypeError: Invalid host defined options」を検索するとドンピシャのものが出てくる。

TypeError: Invalid host defined options · Issue #362 · sindresorhus/gulp-imagemin

これにプラグインの作者が下記 gist へのリンクを貼り付けている。

Pure ESM package

いろいろ書かれているが、とりあえず「gulp-imagemin v8.0.0 はピュア ESM パッケージ」なのだという。ということは import/export を使うのは間違っているわけではなさそうだ。

では、esm モジュールをインストールして import/export が使えるようになったはずの gulpfile.esm.js が悪いのだろうか。

冒頭の gulp の Transpilation のリンクをよく見ると、

Most new versions of node support most features that TypeScript or Babel provide, except the import/export syntax. When only that syntax is desired, rename to gulpfile.esm.js and install the esm module.

とある。

ほとんどの新しいバージョンのノードは、import/export 構文を除いて、TypeScript または Babel が提供するほとんどの機能をサポートしています。その構文のみが必要な場合は、gulpfile.esm.jsにリネームして esm モジュールをインストールしてください。(筆者訳)

~ except the import/export syntax. のあとに When only that syntax is desired, ~ と続いているので、「import/export 構文のみが必要な場合は〜」と読める。

なるほど、esm モジュールには import/export のサポートしかないのかもしれない。それしかないので、ピュア ESM パッケージである gulp-imagemin が動かない、という可能性がある。

なぜエラーが出るのかの答えは「esm モジュールが期待と違いそうだった」となりそうだ。それ以上の原因究明には esm モジュールと gulp-imagemin プラグインのソースコードを両方読まなければならなそうでやめた。

gulp-imagemin に問題がないとわかれば、gulp を ESM で動作させる他の方法を検証すればよい。そうして記事前半の解決方法の提示に至った。

gulp や Node.js 互換のスクリプトを ESM に変更すべきか

すべきだろう。

Node.js が直ちに CommonJS のサポートをやめるとは思えないが、v12 で ESM をサポートしたのも JavaScript の標準仕様に倣うものであるし、いずれは ESM をデフォルトの実行環境とするのは想像に難くない。

今は .js ファイルはデフォルトで CommonJS で実行されるが、どこかのタイミングで ESM になり、CommonJS で実行するほうがオプションになると予想する。

gulp-imagemin に限らず、他の gulp プラグインや Node.js プラグインもそう時を待たずに ESM 化していくだろう。

とは言え、CommonJS の require() は JSON ファイルもパースしてくれるで便利だ。例えば、

// ./package.json
{
  "name": "awesome-module",
  "version": "0.1.0"
}

という JSON ファイルの version を引き当てたいとき、CommonJS なら次のようにすれば動く。

// index.js(CommonJS)
const { version } = require("./package");

console.log(version); // => 0.1.0

これを ESM でやると次のようになる。

// index.mjs(ESM)
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

// Node.js 環境向けの置き換え
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const _package = readFileSync(resolve(__dirname, "./package.json"), "utf8");

console.log(_package.version); // => undefined

let parsedPackage;
try {
  parsedPackage = JSON.parse(_package);
} catch (e) {
  console.error(e);
}

console.log(parsedPackage.version); // => 0.1.0

このように、ESM の import 構文は JSON ファイルを読み込めないので fs.readFile()fs.readFileSync() を使わなくてはいけない。

そして fs モジュールはファイルシステムとしての I/O しかないので、それを JSON として扱うには JSON.parse() を経由する必要がある。さらに JSON.parse() はパースできないと例外を返すので try catch もしなくてはならない。

こうした変更は ESM 化するなら避けられない作業なので頑張ろう。

あちこちで使うならユーティリティにしておこう。

// ./utilities/getParsedJSON.mjs
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

export const getParsedJSON = (filepath) => {
  try {
    return JSON.parse(readFileSync(resolve(__dirname, filepath), "utf8"));
  } catch (e) {
    console.error(e);
  }
};
// index.mjs
import { getParsedJSON } from "./utilities/getParsedJSON.mjs";

const parsedPackage = getParsedJSON("./package.json");

console.log(parsedPackage.version); // => 0.1.0

以上、gulpfile.esm.js で gulp-imagemin v8.0.0 を利用するとエラーになる、だった。

gulp-imagemin 以外のピュア ESM パッケージでも同じエラーが起こる可能性があるわけで、こうなってしまうと gulpfile.esm.js を使う理由はないに等しい。個人的には .mjs を使う方法を推す。みんなも ESM しようぜ!

Special thanks @watilde!