Skip to main content

2024年にESMとCJSの両方に対応するnpmパッケージを作成する

著者
blog feature multithreading

2024年4月18日

0 分で読めます
How to Build a Secure NPM Package for ESM and CJS

ECMAScript Modules(ESM)とCommonJS(CJS)の両方に対応するJavaScriptパッケージの公開は、幅広いライブラリを統合したい開発者にとって重要なスキルです。

この記事では、ESMとCJSのサポートを維持するための実践的な方法とベストプラクティスを紹介します。両方に対応するライブラリで”type: module”宣言を避けることの影響を確認し、エントリーポイントを使い分けるためのpackage.jsonのmainフィールドとmoduleフィールドの使い方を見ていきます。

また、モジュール解決の制御に不可欠なexportsフィールドをpackage.jsonファイルで使う目的と方法についても解説します。さらに、TypeScriptプロジェクトについては、パッケージマニフェストのexportsとモジュールタイプを連携させ、互換性と型安全性の両方を確保する方法を紹介します。

以下のコードを順を追って試してみてください。すべての再現例を含むGitHubコードリポジトリpackage-json-exportsも用意しています。オープンソースのnpmプロキシプロジェクトVerdaccioを利用しています。

ESMとCJSに対応するライブラリでは「Type: Module」の定義を避ける

package.jsonファイルで”type”フィールドを省略すると、デフォルトでcommonjsが暗黙的に設定されることをご存じですか?たとえば、次のようになります。”type”: “commonjs”。

package.jsonファイルに”type”: “module”を追加すると、そのライブラリがESMプロジェクトのみを対象とすることを明示的に指定します。パッケージマニフェストには引き続きmainフィールドを定義する必要があり、ESM対応モジュールのエクスポートを反映するように更新されます。

具体例として、次のpackage.jsonの定義は「純粋な」ESMであるにもかかわらず、上流のESM利用者では動作しません。

{
  "name": "math-add",
  "version": "1.2.0",
  "description": "",
  "module": "src/index.mjs",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}

moduleの定義はESMモジュールであり、typeによってこのライブラリがESM利用者を対象としていることも明確です。しかし、package.jsonファイルにmainフィールドがありません。そのため、Node.jsはパッケージを見つけられないというエラーを出して例外をスローします。たとえば、次のようになります。

node:internal/modules/esm/resolve:205
  const resolvedOption = FSLegacyMainResolve(packageJsonUrlString, packageConfig.main, baseStringified);
                         ^

Error: Cannot find package '/~/package-json-exports/consumer-esm/node_modules/math-add/package.json' imported from /~/package-json-exports/consumer-esm/server.js

まとめると、npmパッケージのパッケージマニフェストでは”type”: “module”宣言を使わないようにしましょう。

package.jsonファイルのmainフィールドとmoduleフィールドの使い方を見て、これらがより適切な指定方法である理由を解説します。

mainフィールドとmoduleフィールドによるESMとCJSの互換性

ESMが登場する前は、package.jsonファイルのmainフィールドで、Node.jsランタイムにパッケージのエントリーポイントを指定していました。通常、開発者はルートディレクトリにindex.jsやapp.jsファイルを置き、mainフィールドでそのファイルを指定していました。たとえば、”main”: “index.js”のようにします。

package.jsonファイルでmainフィールドを省略すると、Node.jsランタイムはパッケージのルートディレクトリにあるserver.jsをファイル規則に基づいてエントリーポイントとして解決します。

ESMとCJSの両方に対応するnpmパッケージを定義するには、mainでCJSエクスポートを指定し、moduleでESMエクスポートを指定する方法が使えます。

ライブラリ:

{
  "name": "math-add",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.cjs",
  "module": "src/index.mjs",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}

上流の利用側にはCJSプロジェクトとESMプロジェクトの両方があります。CJSプロジェクトはsrc/index.cjsファイルを、ESMプロジェクトはsrc/index.mjsファイルを利用します。どちらの利用側もmath-add依存関係について特別な指定をする必要はなく、そのまま動作します。

package.jsonのexportsフィールドを理解する

package.jsonファイルでexportsフィールドを使うと、npmパッケージからエクスポートする要素と、その利用方法をさらに細かく制御できます。

たとえば、Node.jsランタイムがrequire(‘math-add’)でnpmパッケージを読み込もうとした場合にエントリーファイルのフルパスを指定し、import .. from ‘math-add’)で読み込もうとした場合には、まったく別のファイルをエントリーとして指定できます。

以下は、説明したデュアルモードのCJS・ESMパッケージのコード例です。

{
  "name": "math-add",
  "version": "1.5.0",
  "description": "",
  "exports": {
    ".": {
      "require": "./src/index.cjs",
      "import": "./src/index.mjs"
    }
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}

TypeScriptプロジェクトでのpackage.jsonのexportsとモジュールタイプ

TypeScriptでパッケージのESMコードを記述し、CJSとの後方互換性も維持したい場合は、typesを宣言し、CJS部分のTypeScriptコンパイルとトランスパイルも設定する必要があります。

TypeScriptのコンパイルとバンドルには、tsupをおすすめします。次にbuildスクリプトのステージを用意します。CIジョブやnpmパッケージの手動公開を実行する前に、必ずビルドを実行してください。

完全な例を以下に示します。

{
  "name": "math-add",
  "version": "1.5.0",
  "description": "",
 "main": "./dist/index.js",
 "module": "./dist/index.mjs",
 "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.js",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsup src/index.ts --format cjs,esm --dts --clean",
    "watch": "npm run build -- --watch src",
    "prepublishOnly": "npm run build"
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}

変更を監視するNode.jsランタイムの新しいサポートも、コマンドラインフラグ--watch srcを使って利用していることにお気づきでしょう。以前はnodemonで実現していました。優れたパッケージですが、依存関係は少ないほうが望ましいです。

次のステップ:2024年の最新npmパッケージの公開と構成

この記事では、JavaScript開発者向けに、プロジェクトでモジュール形式を効果的に扱うための、実践しやすく具体的なポイントを簡潔に紹介しました。

npmパッケージの公開に関するベストプラクティスに従い、TypeScriptの設定、テスト、CI、セキュリティなどをさらに深く考慮した最新のnpmパッケージを作成することも大切です。

オープンソースの依存関係を安全に保つ

Snykは、脆弱なオープンソースの依存関係とその推移的依存関係を、ワンクリックで修正するPRを提供します。

続きを読む

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

feature insights context
Blog

予防は、本質的に解決済みの問題なのでしょうか?

エージェントが生成するコードの予防策はアーキテクチャ上解決されていますが、開発を遅らせることなくセキュリティを守る制御を選ぶことが、依然として課題です。

Live Stream

修正エージェントをわかりやすく解説:見つけるより直すことが重要な理由

SnykのRemediation Agentが、セキュリティインテリジェンス、破壊可能性分析、検証を活用して、脆弱性をマージ可能なプルリクエストに変える仕組みをご覧ください。