30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Node.js における ES Modules を理解する

記事執筆時点での最新版の Node.js では、モジュールシステムとして ES Modules を使うことができる。
また、CommonJS で書かれたモジュールを ES Modules で読み込むこともできる。

Node.js のモジュールシステムは複雑すぎて苦手意識があったので、整理した。

この記事の内容は、Node.js のv14.7.0で動作確認している。
Node.js のモジュールシステムはバージョン毎に挙動が大きく変わるので、注意が必要。

そのファイルは CJS なのか ESM なのか

Node.js で使えるモジュールシステムとして、ES Modules(以下、ESM)の他に CommonJS(以下、CJS)があり、CJS がデフォルトになっている。
Node.js におけるモジュールシステムを理解するためにはまず、Node.js が各ファイルをどのモジュールシステムとして扱うのかを、理解できるようになる必要がある。
CJS で書かれたファイルとして扱われるのか、ESM で書かれたファイルとして扱われるのかで、挙動が変わるためである。

現状、typeof moduleの値で、どちらのモジュールシステムなのかを判断できる。
CJS の場合はobjectに、ESM の場合はundefinedになる。

// CJS
console.log(typeof module); // object

// ESM
console.log(typeof module); // undefined

拡張子による判断

まず、拡張子による判断が行われる。

拡張子が.cjsのファイルは CJS、.mjsのファイルは ESM として扱われる。

// ./src/index.cjs
console.log(typeof module); // object
// ./src/index.mjs
console.log(typeof module); // undefined

そして拡張子が.jsのファイルは、package.jsontypeフィールドの値によって判断される。

package.json の type による判断

package.jsontypeフィールドがcommonjsの場合は、.jsファイルは CJS として扱われる。

// ./src/index.js

console.log(typeof module); // object

commonjsがデフォルト値なので、typeフィールドが存在しない場合も、CJS となる。

typeフィールドをmoduleにすると、.jsファイルが ESM として扱われるようになる。

// ./src/index.js

console.log(typeof module); // undefined

対象のファイルと同じディレクトリにpackage.jsonがない場合は、上位のディレクトリを検索していき、一番近い場所にあるpackage.jsonの内容が反映される。

例えば以下のディレクトリ構成の時に、./package.jsontypeフィールドがmoduleで、./src/utils/package.jsontypeフィールドがcommonjsだったとする。

├── package.json
└── src
    ├── index.js
    └── utils
        ├── index.js
        └── package.json

そうすると、各.jsファイルの実行結果は、次のようになる。

// ./src/index.js

// ESM として扱われている
console.log(typeof module); // undefined
// ./src/utils/index.js

// CJS として扱われている
console.log(typeof module); // object

なお、拡張子が.cjs.mjsのファイルについては、typeフィールドの影響は受けない。

ESM 同士の挙動

各ファイルがどちらのモジュールシステムで扱われるのかを理解できたので次は、ファイルのインポートとエクスポートについて見ていく。

まずは、ESM 同士の挙動について。

// ./src/utils/foo.mjs
export default 1;
export const x = 2;
// ./src/index.mjs
import Foo, {x} from './utils/foo.mjs';

console.log(Foo); // 1
console.log(x); // 2

特に問題なく扱える。特定のフラグやオプションが必要になることもない。

拡張子の省略はできない。

// ./src/index.mjs

// Error [ERR_MODULE_NOT_FOUND]
import Foo, {x} from './utils/foo';

--es-module-specifier-resolution=nodeフラグをつけて実行すると、拡張子が省略可能になる。

$ node --es-module-specifier-resolution=node src/index.mjs
1
2

また、このフラグをつけている場合、パスとしてディレクトリを指定することでindexファイルを読み込む、ということも可能になる。

// ./src/utils/index.mjs
export default 9;
// ./src/index.mjs
import Foo from './utils/';

console.log(Foo); // 9

CJS 同士の挙動

次は、CJS 同士の挙動について。

// ./src/utils/bar.cjs
module.exports = {
  y: 8,
}
// ./src/index.cjs
const Bar = require('./utils/bar.cjs');

console.log(Bar); // { y: 8 }

特に問題ないが、こちらも、拡張子の省略について注意点がある。
.cjsファイルの拡張子を省略するとエラーになる。
そのため、上記の./src/index.cjsを以下のように書き換えると、エラーになってしまう。

// ./src/index.cjs

// Error: Cannot find module './utils/bar'
const Bar = require('./utils/bar');

CJS として扱われている.jsファイルを読み込む場合は、拡張子を省略できる。

// ./src/utils/bar.js
module.exports = {
  y: 6,
}
// ./src/index.cjs
const Bar = require('./utils/bar');

console.log(Bar); // { y: 6 }

ディレクトリを指定することでindex.jsを読み込むこともできる。これも、index.cjsを読み込もうとするとエラーになるので注意。

// ./src/utils/index.js
module.exports = {
  y: 56,
}
// ./src/index.cjs
const Bar = require('./utils/');

console.log(Bar); // { y: 56 }

ESM から CJS を読み込む

デフォルトインポートは、問題なく行える。

// ./src/utils/bar.cjs
module.exports = {
  y: 8,
}
// ./src/index.mjs
import Bar from './utils/bar.cjs';

console.log(Bar); // { y: 8 }

名前付きインポートは、エラーになる。

// ./src/index.mjs

// SyntaxError
import {y} from './utils/bar.cjs';

これは、CJS から読み込むものはdefaultでラップされるため、このような挙動になる。

// ./src/index.mjs
import * as Bar from './utils/bar.cjs';

console.log(Bar); // [Module] { default: { y: 8 } }
console.log(Bar.default); // { y: 8 }
console.log(Bar.default.y); // 8

但し、標準モジュールは名前付きインポートを行える。

// ./src/index.mjs
import {readFile} from 'fs';

console.log(readFile); // [Function: readFile]

CJS から ESM を読み込む

以下の ESM ファイルを、CJS ファイルで読み込んでみる。

// ./src/utils/foo.mjs
export default 1;
export const x = 2;

まず、ESM ファイルに対してrequireを実行すると、エラーになる。

// ./src/index.cjs

// Error [ERR_REQUIRE_ESM]
const Foo = require('./utils/foo.mjs');

CJS ファイルのなかでimport文を使うと、シンタックスエラーになる。

// ./src/index.cjs

// SyntaxError: Cannot use import statement outside a module
import {Foo} from './utils/foo.mjs';

但し Dynamic import は実行可能なので、それを使って ESM ファイルを読み込むことができる。

// ./src/index.cjs

import('./utils/foo.mjs').then(res => {
  console.log(res); // [Module] { default: 1, x: 2 }
});

参考資料

Let's Encrypt と Route 53 でローカル開発環境を HTTPS 化する

ブラウザの機能のなかには、HTTPS でないと利用できなかったり、HTTPS か HTTP かで挙動が変わったりする機能がある。
そのため、ローカル開発環境を HTTPS で構築したいことがある。

一番簡単なのは自己署名証明書を作成し利用することだと思うが、その場合ブラウザが警告を出すため、利便性の点で難がある。

以下の記事では、Let's Encrypt で取得した正規の証明書を使って、ローカル開発環境を HTTPS 化している。
これなら、ブラウザが警告を出すことはない。

blog.jxck.io

勉強がてら、この内容を実践してみた。

この方法を試すためには、自由に使えるドメインを所有している必要がある。
既にnumb86.netというドメインを所有していたので、これを利用してlocalhost.numb86.netというドメインで開発環境を作っていく。

A レコードの値として 127.0.0.1 を設定する

127.0.0.1はループバックアドレスという特殊な IP アドレスで、自分自身を指す。

例えば、以下のコードを Deno で実行してhttp://127.0.0.1:8080/にアクセスすると、Hello Deno.と表示される。
なお、Deno のバージョンは1.2.2

import {
  listenAndServe,
} from "https://deno.land/std@0.63.0/http/mod.ts";

listenAndServe({ port: 8080 }, (req) => {
  req.respond({
    status: 200,
    headers: new Headers({
      "content-type": "text/plain",
    }),
    body: "Hello Deno.\n",
  });
});

console.log("Server running on localhost:8080");

この127.0.0.1localhost.numb86.netと紐付けることで、localhost.numb86.netへのアクセスがローカル開発環境に対するアクセスとなるようにする。
元々ネームサーバとして Route 53 を利用していたので、その管理画面で設定を行う。

f:id:numb_86:20200804222729p:plain

digコマンドで、正しく設定されていることを確認する。

$ dig localhost.numb86.net a +short
127.0.0.1

これで、http://localhost.numb86.net:8080/にアクセスしてもHello Deno.が表示されるようになった。

次は、localhost.numb86.netの証明書を Let's Encrypt で取得する。

DNS 認証による証明書の取得

Let's Encrypt は ACME(Automatic Certificate Management Environment)というプロトコルを利用しており、そのプロトコルで定義されている「チャレンジ」によって、申請の正当性をチェックしている。
今回の例で言えば、証明書を取得しようとしている私が本当にlocalhost.numb86.netの管理者であるかを確認するために、チャレンジが行われる。

チャレンジにはいくつか種類があるが、今回のようなケースでは「DNS-01」というチャレンジを使う。

まず、certbotというツールをインストールする。

$ brew install certbot
$ certbot --version
certbot 1.6.0

certbotは ACME のクライアントのひとつ。

以下のコマンドで、チャレンジを行う。

$ sudo certbot certonly --manual -d localhost.numb86.net --preferred-challenges dns-01

いくつか質問に答えていくと、以下のように表示される。

Please deploy a DNS TXT record under the name
_acme-challenge.localhost.numb86.net with the following value:

XXX

Before continuing, verify the record is deployed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

XXXの部分にトークンが表示される。
それを、_acme-challenge.localhost.numb86.netの TXT レコードの値として設定する。
表示されている通り、TXT レコードの設定が終わるまではエンターキーは押さない。

これも Route 53 で設定する。

f:id:numb_86:20200804222743p:plain

設定が反映されているか、digコマンドで確認する。トークンが表示されれば、設定は完了している。

$ dig _acme-challenge.localhost.numb86.net txt +short
"XXX"

設定が完了した状態でエンターキーを押すと、チャレンジが完了し、証明書が手に入る。

/etc/letsencrypt/live/localhost.numb86.net/にインストールされている。
cert.pemが証明書で、privkey.pemが秘密鍵。
このディレクトリにアクセスするには権限が必要だったので、sudo surootに切り替えて操作した。

また、ファイルのパーミッションを変えておかないと取り扱いづらいので、以下のコマンドも実行した。

$ sudo chmod 664 privkey.pem

Deno で HTTPS サーバを立てる

証明書が手に入ったので、それを使って HTTPS サーバを立てる。

以下のコードを Deno で実行してhttps://localhost.numb86.net:8443/にアクセスすると、Hello Secure Deno!と表示される。

import {
  listenAndServeTLS,
} from "https://deno.land/std@0.63.0/http/mod.ts";

listenAndServeTLS(
  { port: 8443, certFile: "./cert.pem", keyFile: "./privkey.pem" },
  (req) => {
    req.respond({
      status: 200,
      headers: new Headers({
        "content-type": "text/plain",
      }),
      body: "Hello Secure Deno!\n",
    });
  },
);

console.log("Server running on localhost:8443");

自己署名証明書ではないので、警告も出ない。

f:id:numb_86:20200804222654p:plain

webpack-dev-server で HTTPS サーバを立てる

この環境を使って、webpack-dev-server で HTTPS サーバを立てることもできる。

以下のバージョンで動作確認をした。

  • webpack@4.44.1
  • webpack-cli@3.3.12
  • webpack-dev-server@3.11.0

以下のファイルと、上記の秘密鍵、証明書を用意した上で$ yarn run webpack-dev-server --mode=developmentを実行すると、https://localhost.numb86.net:8443/Hello webpack!と表示される。

<!-- ./dist/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>webpack</title>
</head>
<body>
<div id="app"></div>
<script src="./index.js"></script>
</body>
</html>
// ./src/index.js
const elem = document.querySelector('#app');
elem.textContent = 'Hello webpack!';
// ./webpack.config.js
const path = require('path');
const fs = require('fs');

module.exports = () => {
  return {
    entry: {
      index: './src/index.js',
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
    },
    devServer: {
      contentBase: path.resolve(__dirname, 'dist'),
      host: 'localhost.numb86.net',
      port: 8443,
      https: true,
      key: fs.readFileSync('./privkey.pem'),
      cert: fs.readFileSync('./cert.pem')
    },
  }
};