2023 年 4 月 18 日にリリースされた Node.js v20 の主な変更点を紹介します。
- ファイルのアクセスやプロセスの起動を制限する新しいパーミッションモデル (experimental)
- V8 11.3 による新しい JavaScript の機能
- テストランナー(node:test)が stable に昇格
- Single Executable Application JSON の config を使った Blob が必要になった
- ESM Loaders API の Worker 内での実行
- Ada 2.0 による URL パーサーの高速化
- Web Crypto API が WebIDL に合わせて互換性改善
- Deprecation and Removals
- その他の変更
- まとめ
- 参考記事
ファイルのアクセスやプロセスの起動を制限する新しいパーミッションモデル (experimental)
実験的な機能として 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 のパーミッションモデルに大変似ています。
Node.js の Permission Model の API ドキュメントは次のリンク先にあります。
これはまだ実験的な機能のため 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
macOS の zsh を使っている場合は --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
こちらも macOS の zsh を使っている場合は --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 についても解説されています。こちらも併せてお読みください。
V8 11.3 による新しい JavaScript の機能
JavaScript エンジンである V8 が 11.3 にアップデートしました。
それにより新しい JavaScript の機能が追加されています。
それぞれの機能については次の記事が詳しいので、そちらをお読みください。
テストランナー(node:test
)が stable に昇格
Node.js 本体に組み込まれているテストランナーである node:test
が stable になりました。
Node.js のテストランナーである node:test
は Jest や Vitest などのテストランナーと同じようなインターフェイスを持つテストランナーです。Node.js 本体に組み込まれているため、Jest や Vitest などを別途インストールすることなくテストコードを実行することができます。詳しくはドキュメントをお読みください。
Node.js のテストランナー(node:test
)自体は Node.js v18 から実験的な(experimental)機能として実装されていました。
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 が必要になった
Single Executable Application (以下 SEA)は Node.js で実行可能な JavaScript を単一の実行ファイルにする機能です。
まだ実験的な(experimental)機能ですが、Node.js v18.16.0 から利用可能です。
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 のチームによってメンテされています。
7. Node.js の実行ファイル(コピー)に再署名をする
macOS の場合、
$ codesign --sign - hello
Windows の場合、
$ signtool sign /fd SHA256 hello
8. バイナリを実行する
バイナリに引数(World!
)を渡して実行してみると次のような結果になります。
$ ./hello World! Hello, World!
ESM Loaders API の Worker 内での実行
ESM の Loadeers API がそもそも何かというのは次の記事内で言及しているので、そちらをお読みください。
ざっくり言うと、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
を経由しないといけなくなります。
また、import.meta.resolve()
が同期実行に変わります。
Ada 2.0 による URL パーサーの高速化
Node.js v19.7.0 から Node.js 内部の URL パーサーが Ada に移行されました。
twitter.comNode.js is moving to a new, faster URL parser called Ada. Check out more information in their initial release notes at https://t.co/QISZ0Ujm4i
— Node.js (@nodejs) 2023年2月6日
Ada は C++で実装されており、WHATWG の URL Standard の URL Parsing の仕様に沿っています。
その Ada が 2.0 になったことで高速化されました。
次の記事内によると、100,000 個の URL を利用してベンチーマークを計測したところ、 Ada を利用した Node.js はDeno より 3 倍、Bun より 82%も高速という結果になったとのことです。
Web Crypto API が WebIDL に合わせて互換性改善
WebCrypto API は JavaScript で暗号化や復号、署名やその検証等の処理を行うことができる Web 標準の API です。Web 標準のためブラウザでも Node.js でも同じように使うことができます。
Node.js v15 に experimental な機能として実装され、Node.js v19 で stable に昇格しました。
その Web Crypto API の互換性の改善がされました。 Web Crypto API の関数に引数に正しい型のデータを渡さないとエラーになります。 また、エラーコードも変更になったとのことです。
たとえば、以下のようなコードは第2引数が ArrayBuffer
、Buffer
、TypedArray
、DataView
を期待しているため、エラーになります。
await crypto.subtle.digest("sha-256", []);
その他の仕様については API ドキュメントをお読みください。
Deprecation and Removals
url.parse()に渡すポートに数値以外を渡すと Deprecation 警告
url.parse()
に数値以外を渡すことはすでにドキュメント上では Deprecate になっていました。
しかし、Node.js v20 からは実行時に警告が表示されるようになります。
まだ削除はされていないのですが、削除される日も近いかもしれませんので、もし警告が出た場合は直ちに修正してください。
その他の変更
ARM64 なプロセッサ上で動く Windows が公式にサポートされたり、筆者はあまり知らないのですがnew WASI()
するときの引数のversion
が必須になったりしたようです。
まとめ
Node.js もバージョンが 20 になりました。Node.js v20 はバージョンが偶数なので 10 月頃に LTS になる予定です。この記事の執筆時点では 2023 年 10 月 24 日に LTS になる予定です。
日程についての最新の情報は以下のリポジトリをご確認ください。
Node.js にも Deno のような Permission Model が実装されました。これがデフォルトで有効になるのはまだ先からもしれませんが、かなり大きな変更です。
Deno も npm package が使えるようになったり、今後もお互いの機能や利点を実装していて機能差が少なくなっていくのでしょうか。
Single Executable Application は vercel/pkg を置き換えるかもしれません。
C や Rust で作った実行ファイルに比べると、Node.js 本体の実行ファイルが必要なため実行ファイルのサイズが大きくなるかもしれませんが、JavaScript を使う開発者でも単一の実行ファイルを Node.js をインストールしていないユーザーにも配布できるようになるのは良いと思います。
また、Ada による URL パーサーの高速化もおもしろいですね。参考記事内のベンチマークをどこまで信じていいかはわかりませんが、かなり高速化されているのは嬉しいです。
長くなってしまいましたが、最後までお読みいただきありがとうございました。不備や質問がございましたら、@shisama_までメンションするかブコメなどでコメントください。