別にしんどくないブログ

技術のことや読書メモを書いています

Node.js v20 の主な変更点

2023 年 4 月 18 日にリリースされた Node.js v20 の主な変更点を紹介します。

nodejs.org

ファイルのアクセスやプロセスの起動を制限する新しいパーミッションモデル (experimental)

github.com

実験的な機能として Process-based Permission Model という機能が追加されました。
Node.js のプロセスから次の事を制限します。

  • ファイルの読み込み
  • ファイルの書き込み
  • 子プロセス(child_process)の利用
  • Worker Threads の利用

これにより悪意のあるプログラムを誤って実行してしまってもコンピュータ内のファイルの読み込みによる情報漏えいやファイルの改ざん、悪意のあるプログラムの実行を防ぐことができます。

それぞれ許可するためのフラグも用意されています(後述)。

  • --allow-fs-read: ファイルの読み込みを許可
  • --allow-fs-write: ファイルの書き込みを許可
  • --allow-child-process: child_process の実行を許可
  • --allow-worker: Worker Threads の実行を許可

Deno のパーミッションモデルに大変似ています。

deno.land

Node.js の Permission Model の API ドキュメントは次のリンク先にあります。

nodejs.org

これはまだ実験的な機能のため Node.js を実行するときに --experimental-permission というフラグが必要です。

$ node --experimental-permission index.js

例として、以下のような Node.js のプログラムがあったとします。

const fs = require("node:fs");
const path = require("node:path");

fs.readFileSync("/path/to/test.txt");

このプログラムは単純にtest.txtというファイルを読み込むだけです。実行すると以下のような結果を得ることができます。

$ node index.js
Hello

--experimental-permissionを付与することでファイル読み込みを制限しエラーとして実行終了します。

$ node --experimental-permission index.js

node:internal/modules/cjs/loader:179
  const result = internalModuleStat(filename);
                 ^

Error: Access to this API has been restricted
    at stat (node:internal/modules/cjs/loader:179:18)
    at Module._findPath (node:internal/modules/cjs/loader:651:16)
    at resolveMainPath (node:internal/modules/run_main:15:25)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:76:24)
    at node:internal/main/run_main_module:23:47 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/path/to/index.js'
}

Node.js v20.0.0

--allow-fs-read でファイルの読み込みを許可する

特定のファイルの読み込みは許可したいケースはあると思います。
そういった場合は --allow-fs-read というフラグを付与します。 たとえば、/path/to/allowedというディレクトリ内にあるファイルを読み込みたいときは次のように指定します。

$ node --experimental-permission --allow-fs-read=/path/to/allowed index.js

/path/to/allowdしか許可されていないので、他のディレクトリ内のファイルは許可されません。ただし、サブディレクトリは許可されます。

fs.readFileSync("/path/to/allowed/test.txt"); // OK
fs.readFileSync("/path/to/allowed/sub/test.txt"); // サブディレクトリはOK
fs.readFileSync("/path/to/forbidden/test.txt"); // 別ディレクトリはNG
fs.readFileSync("/path/to/test.txt"); // 親ディレクトリもNG

すべてのファイル読み込みを許可したいときは --allow-fs-read=* を指定します。

$ node --experimental-permission --allow-fs-read=* index.js

macOSzsh を使っている場合は --allow-fs-read=* ではなく、--allow-fs-read=\* としなければいけない点に注意してください。macOS でも bash の場合、--allow-fs-read=* で実行できました。

複数指定する場合は、,で区切って指定します。

$ node --experimental-permission --allow-fs-read=/path/to/allowed,/home index.js

静的解析ツールなどを使ったプロジェクト配下のファイルだけを読み込みたいときは、以下のようにプロジェクト直下から実行することを想定してカレントディレクトリを指定するのがよくあるパターンかなと思います。

node --experimental-permission --allow-fs-read=$(PWD) index.js

--allow-fs-write によるファイル書き込み許可

--experimental-permission を付与すると、ファイルの読み込みだけでなく書き込みも制限されます。
それにより悪意のあるコードによるファイルの改ざんを防ぐことができます。

しかし、特定のファイルに対してファイルの書き込みを許可したい場合があると思います。
その場合、--allow-fs-write というフラグを利用することで特定のファイルへの書き込みを許可できます。

たとえば、次のテキストファイルを書き込むプログラムがあったとします。

const fs = require("node:fs");
const path = require("node:path");

fs.writeFileSync("/path/to/test.txt", "Hello");

これを --experimental-permission なしで実行すると、/path/to/test.txt というファイルに Hello と書き込まれます。

しかし、--experimental-permission を付与して実行するとエラーになりファイルの書き込みはされません。 そこで次のように --allow-fs-write フラグを指定して /path/to/allowed ディレクトリ配下のファイルへの書き込みが可能になります。

$ node --experimental-permission --allow-fs-read=* --allow-fs-write=/path/to/allowed index.js

複数指定する場合は、,で区切って指定します。

$ node --experimental-permission --allow-fs-read=* --allow-fs-write=/path/to/allowed,/home index.js

--allow-fs-write--allow-fs-readと同様に指定したディレクトリのファイルのみが許可され、それ以外のファイルへの書き込みは許可されません。

fs.writeFileSync("/path/to/allowed/test.txt", "Hello"); // OK
fs.writeFileSync("/path/to/allowed/sub/test.txt", "Hello"); // サブディレクトリはOK
fs.writeFileSync("/path/to/forbidden/test.txt", "Hello"); // 別ディレクトリはNG
fs.writeFileSync("/path/to/test.txt", "Hello"); // 親ディレクトリもNG

--allow-fs-read同様、すべてのファイル読み込みを許可したいときは --allow-fs-write=* を指定します。

$ node --experimental-permission --allow-fs-read=* --allow-fs-write=* index.js

こちらも macOSzsh を使っている場合は --allow-fs-write=* ではなく、--allow-fs-write=\* としなければいけない点に注意してください。

--allow-child-process による child_process の許可

Node.js からプロセスを起動するときに使われる child_process を利用したいときは --allow-child-process フラグを付与します。

たとえば、次のようなファイルがあったとします。

const { spawn } = require("node:child_process");
const ls = spawn("ls", ["-lh", "."]);

ls.stdout.on("data", (data) => {
  console.log(`stdout: ${data}`);
});

これを --experimental-permission を付与して実行するとエラーが発生し、spawnは実行されません。

$ node --experimental-permission --allow-fs-read=* index.js

node:internal/child_process:395
  const err = this._handle.spawn(options);
                           ^

Error: Access to this API has been restricted
    at ChildProcess.spawn (node:internal/child_process:395:28)
    at spawn (node:child_process:757:9)
    at Object.<anonymous> (/path/to/index.js:2:12)
    at Module._compile (node:internal/modules/cjs/loader:1267:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1321:10)
    at Module.load (node:internal/modules/cjs/loader:1125:32)
    at Module._load (node:internal/modules/cjs/loader:965:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12)
    at node:internal/main/run_main_module:23:47 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'ChildProcess',
  resource: ''
}

エラーメッセージを良く見ると、permission: 'ChildProcess' と記載されています(下から 2 つ目の項目)。
このエラーが出たときは次のように --allow-child-process を付けないと実行は成功しません。

$ node --experimental-permission --allow-fs-read=* --allow-child-process index.js

--allow-worker による Worker Threads の許可

worker_threads を使ってマルチスレッドを利用したい場合、--allow-worker フラグを付与しなければいけません。

たとえば、次のようなプログラムがあったとします。

const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");

これを --allow-workerを付けずに実行すると次のようなエラーが発生します。

$ node --experimental-permission --allow-fs-read=* index.js

node:internal/worker:211
    this[kHandle] = new WorkerImpl(url,
                    ^

Error: Access to this API has been restricted
    at new Worker (node:internal/worker:211:21)
    at Object.<anonymous> (/path/to/index.js:2:16)
    at Module._compile (node:internal/modules/cjs/loader:1267:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1321:10)
    at Module.load (node:internal/modules/cjs/loader:1125:32)
    at Module._load (node:internal/modules/cjs/loader:965:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12)
    at node:internal/main/run_main_module:23:47 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'WorkerThreads',
  resource: ''
}

次のように--allow-workerフラグを付与して実行すると Woker のプログラムは実行されます。

$ node --experimenta-permission --allow-fs-read=* --allow-worker index.js

Permission Model  の参考記事

以下の記事では Process-based Permissions だけでなく、Module-based Permissions についても解説されています。こちらも併せてお読みください。

zenn.dev

V8 11.3 による新しい JavaScript の機能

JavaScript エンジンである V8 が 11.3 にアップデートしました。
それにより新しい JavaScript の機能が追加されています。

それぞれの機能については次の記事が詳しいので、そちらをお読みください。

zenn.dev

テストランナー(node:test)が stable に昇格

github.com

Node.js 本体に組み込まれているテストランナーである node:test が stable になりました。

Node.js のテストランナーである node:test は Jest や Vitest などのテストランナーと同じようなインターフェイスを持つテストランナーです。Node.js 本体に組み込まれているため、Jest や Vitest などを別途インストールすることなくテストコードを実行することができます。詳しくはドキュメントをお読みください。

shisama.hatenablog.com

Node.js のテストランナー(node:test)自体は Node.js v18 から実験的な(experimental)機能として実装されていました。

shisama.hatenablog.com

Node.js v18 で実装されてから多くの機能追加がされました。以下はその一例です。

  • describe
  • before, beforeEach, after, afterEach
  • Mock mock.fn, t.mock.method, mock.resetなど
  • カバレッジ

しかし、stable 化への要望が多いことや大きな問題もないことから Node.js v20 から stable になりました。 ただし、カバレッジレポートの取得機能はまだ experimental なため、-experimental-test-coverageフラグが必要です。

Single Executable Application JSON の config を使った Blob が必要になった

github.com

Single Executable Application (以下 SEA)は Node.js で実行可能な JavaScript を単一の実行ファイルにする機能です。
まだ実験的な(experimental)機能ですが、Node.js v18.16.0 から利用可能です。

nodejs.org

Node.js v20 からはこの実行ファイルのバイナリ生成方法が変わりました。
ざっくり言うと、JavaScript コードを Blob に変換し、Node.js 本体のバイナリに対して Blob を挿入して1つのバイナリにします。

Single Executable Application による実行ファイルのバイナリ生成手順

実行ファイルを生成する方法は上記のリンク先の API ドキュメントにも記載されていますが、簡単に説明します。

1. Node.js で実行可能な JavaScript ファイルを用意する

console.log(`Hello, ${process.argv[2]}!`);

2. 実行ファイルに挿入する Blob ファイルを生成するための config ファイルの JSON を用意する

{
  "main": "hello.js",
  "output": "sea-prep.blob"
}

mainには 1 で用意した JavaScript ファイルのパスを記載する。outputは出力される Blob のファイル名を指定する。何でも良い。

3. Blob を生成する

$ node --experimental-sea-config sea-config.json

このコマンドが成功するとsea-preb.blobという名前のファイルが生成されるはずです。

4. Node.js の実行ファイルをコピーする

$ cp $(command -v node) hello

command -v nodeで Node.js の実行ファイルのあるパスを取得しています。/path/to/nodeに実態があるのであれば、$(command -v node)の部分を/path/to/nodeとしても同じ内容になります。 $(command -v node)の部分は Node.js の実行ファイルであることに注意してください。nodenv などを使っている場合は Node.js の実行ファイルではなく、実行ファイルを叩く shell スクリプトになっていたりします。

ここまでいくとhelloという名前の Node.js 本体の実行ファイルがコピーされます。

5. コピーした Node.js の実行ファイルから署名を削除する

macOS の場合、

$ codesign --remove-signature hello

Windows の場合、

$ signtool remove /s hello

6. postject を使ってコピーした Node.js の実行ファイルに Blob を挿入する

postject というツールを使ってコピーした Node.js の実行ファイルに Blob を挿入します。

$ npx postject hello NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA

postject も Node.js のチームによってメンテされています。

github.com

7. Node.js の実行ファイル(コピー)に再署名をする

macOS の場合、

$ codesign --sign - hello

Windows の場合、

$ signtool sign /fd SHA256 hello

8. バイナリを実行する

バイナリに引数(World!)を渡して実行してみると次のような結果になります。

$ ./hello World!
Hello, World!

ESM Loaders API の Worker 内での実行

github.com

ESM の Loadeers API がそもそも何かというのは次の記事内で言及しているので、そちらをお読みください。

shisama.hatenablog.com

ざっくり言うと、ESM の仕様に沿っていないモジュールの読み込みのために Custom Loader 関数を定義しておき、実行時に Loader 関数のファイルを指定することで ESM の仕様にないモジュール読み込みが可能になるというものです。

たとえば、次の Custom Loader 用のファイルは CSS を ESM で読み込むためのものです。

/* loader.mjs */
import { URL } from "url";
import { readFile } from "fs/promises";

/**
 * This function loads the content of files ending with ".css" to an ECMAScript Module
 * so the default export is a string containing the CSS stylesheet.
 */
export async function load(url, context, defaultLoad) {
  if (url.endsWith(".css")) {
    const content = await readFile(new URL(url));

    return {
      format: "module",
      source: `export default ${JSON.stringify(content.toString())};`,
    };
  }

  return defaultLoad(url, context, defaultLoad);
}

このファイルを用いると次のように CSS を読み込むことができます。

import styles from "./styles.css" assert { type: "css" };

Custom Loader を利用する場合は--experimental-loaderフラグが必要です。

$ node --experimental-loader=./loader.mjs index.mjs

Node.js v20 からは Custom Loader 用のファイル(上記の loader.mjs)が Worker で実行されるようになります。
それにより、アプリケーション側の実行コンテキストを分けることができます。また、Worker で動くためグローバル変数が共有されなくなるので、メインスレッドとのやりとりは globalPreload を経由しないといけなくなります。

nodejs.org

また、import.meta.resolve()が同期実行に変わります。

Ada 2.0 による URL パーサーの高速化

github.com

Node.js v19.7.0 から Node.js 内部の URL パーサーが Ada に移行されました。

twitter.com

Ada は C++で実装されており、WHATWG の URL Standard の URL Parsing の仕様に沿っています。

url.spec.whatwg.org

その Ada が 2.0 になったことで高速化されました。

次の記事内によると、100,000 個の URL を利用してベンチーマークを計測したところ、 Ada を利用した Node.js はDeno より 3 倍Bun より 82%も高速という結果になったとのことです。

www.yagiz.co

Web Crypto API が WebIDL に合わせて互換性改善

github.com

WebCrypto APIJavaScript で暗号化や復号、署名やその検証等の処理を行うことができる Web 標準の API です。Web 標準のためブラウザでも Node.js でも同じように使うことができます。

Node.js v15 に experimental な機能として実装され、Node.js v19 で stable に昇格しました。

shisama.hatenablog.com

その Web Crypto API の互換性の改善がされました。 Web Crypto API の関数に引数に正しい型のデータを渡さないとエラーになります。 また、エラーコードも変更になったとのことです。

たとえば、以下のようなコードは第2引数が ArrayBufferBufferTypedArrayDataViewを期待しているため、エラーになります。

await crypto.subtle.digest("sha-256", []);

その他の仕様については API ドキュメントをお読みください。

nodejs.org

Deprecation and Removals

url.parse()に渡すポートに数値以外を渡すと Deprecation 警告

github.com

url.parse()に数値以外を渡すことはすでにドキュメント上では Deprecate になっていました。 しかし、Node.js v20 からは実行時に警告が表示されるようになります。 まだ削除はされていないのですが、削除される日も近いかもしれませんので、もし警告が出た場合は直ちに修正してください。

その他の変更

ARM64 なプロセッサ上で動く Windows が公式にサポートされたり、筆者はあまり知らないのですがnew WASI()するときの引数のversionが必須になったりしたようです。

github.com

github.com

まとめ

Node.js もバージョンが 20 になりました。Node.js v20 はバージョンが偶数なので 10 月頃に LTS になる予定です。この記事の執筆時点では 2023 年 10 月 24 日に LTS になる予定です。
日程についての最新の情報は以下のリポジトリをご確認ください。

github.com

Node.js にも Deno のような Permission Model が実装されました。これがデフォルトで有効になるのはまだ先からもしれませんが、かなり大きな変更です。 Deno も npm package が使えるようになったり、今後もお互いの機能や利点を実装していて機能差が少なくなっていくのでしょうか。
Single Executable Application は vercel/pkg を置き換えるかもしれません。
C や Rust で作った実行ファイルに比べると、Node.js 本体の実行ファイルが必要なため実行ファイルのサイズが大きくなるかもしれませんが、JavaScript を使う開発者でも単一の実行ファイルを Node.js をインストールしていないユーザーにも配布できるようになるのは良いと思います。

また、Ada による URL パーサーの高速化もおもしろいですね。参考記事内のベンチマークをどこまで信じていいかはわかりませんが、かなり高速化されているのは嬉しいです。

長くなってしまいましたが、最後までお読みいただきありがとうございました。不備や質問がございましたら、@shisama_までメンションするかブコメなどでコメントください。

参考記事