Read OSS

Node.js のパーミッション、エラー、Web Platform API

上級

前提知識

  • 第1回: architecture-overview
  • 第2回: startup-and-bootstrap(ブートストラップチェーンと Web API の公開)
  • 第4回: javascript-module-system(内部モジュールの読み込み)
  • Web Platform API(fetch、EventTarget、AbortController)の基本的な知識

Node.js のパーミッション、エラー、Web Platform API

これまでのシリーズでは、起動処理・オブジェクトモデル・モジュール・I/O といったテーマを順番に追ってきました。最終回となる本記事では、これらすべてに横断的に関わる仕組みを取り上げます。具体的には、コードの振る舞いを制限するパーミッションモデル、エラーを一貫して扱うためのエラーシステム、Node.js とブラウザの互換性を高める Web API、そしてスナップショットやシングル実行ファイルなど、Node.js のデプロイのあり方を変えつつあるモダンな機能です。

パーミッションモデル

--permission フラグで有効化される実験的なパーミッションモデルは、プロセスが行える操作を C++ レベルで制限します。これはサンドボックスではなく、センシティブな操作が実行される前にパーミッションを検査するケイパビリティシステムです。

アーキテクチャは src/permission/ に集約されており、リソース種別ごとにプラグイン可能なパーミッションクラスが用意されています。

graph TD
    PERM["Permission (permission.h)<br/>Central coordinator"]
    PERM --> FS["FSPermission<br/>--allow-fs-read / --allow-fs-write<br/>RadixTree-based path matching"]
    PERM --> NET["NetPermission<br/>--allow-net<br/>Host/port restrictions"]  
    PERM --> CP["ChildProcessPermission<br/>--allow-child-process<br/>Subprocess spawning"]
    PERM --> INS["InspectorPermission<br/>Inspector protocol access"]
    PERM --> WASI_P["WASIPermission<br/>WASI access control"]
    PERM --> WORK["WorkerPermission<br/>--allow-worker<br/>Worker thread creation"]
    PERM --> ADDON["AddonPermission<br/>--allow-addons<br/>Native addon loading"]

実際の制御は THROW_IF_INSUFFICIENT_PERMISSIONS のようなマクロを通じて行われます。このマクロはセンシティブな操作が発生するあらゆる箇所の C++ コードに散りばめられており、たとえば node_file.cc ではすべてのファイル操作で PermissionScope::kFileSystemRead または kFileSystemWrite のチェックが走ります。

ファイルシステムのパーミッション実装である fs_permission.cc は、パスのプレフィックスマッチングを効率的に行うために RadixTree データ構造を採用しています。--allow-fs-read=/home/user/project を指定すると、許可パスがいくつ設定されていても O(パス長) でパーミッションチェックが完了します。

設計として意図的に粗粒度にしてあります。細かなケイパビリティトークンではなく、アクセスカテゴリをまとめて許可する CLI フラグを使う方式です。ネットワークアクセスやサブプロセスの生成を禁止したい本番環境では、この方が実用的です。

ヒント: パーミッションモデルは実験的な機能ですが、多層防御の観点で有効です。本番アプリを --permission --allow-fs-read=/app --allow-net で起動することで、依存パッケージを経由したサプライチェーン攻撃の影響範囲を絞り込めます。

エラーシステムと安定したエラーコード

Node.js はエラーメッセージを semver の管理対象から切り離すという設計判断を行っています。lib/internal/errors.js(1,938 行)はその実装であり、人間向けのメッセージが改善されても ERR_* コード自体はバージョン間で安定して保たれます。

flowchart TD
    CREATE["Error creation"] --> TYPE{"Error type?"}
    TYPE -->|TypeError| TE["NodeTypeError extends TypeError<br/>Has .code property"]
    TYPE -->|RangeError| RE["NodeRangeError extends RangeError<br/>Has .code property"]
    TYPE -->|Error| E["NodeError extends Error<br/>Has .code property"]
    TYPE -->|SystemError| SE["NodeSystemError<br/>Has .code + .errno + .syscall"]
    
    TE --> CODE["err.code = 'ERR_INVALID_ARG_TYPE'"]
    RE --> CODE2["err.code = 'ERR_OUT_OF_RANGE'"]
    E --> CODE3["err.code = 'ERR_MISSING_OPTION'"]
    SE --> CODE4["err.code = 'ERR_FS_EISDIR'"]

エラーコードは次のような登録パターンで定義されます。

E('ERR_INVALID_ARG_TYPE', (name, expected, actual) => {
  // Message can change between Node.js versions
  return `The "${name}" argument must be ${expected}. Received ${actual}`;
}, TypeError);

安定した契約は ERR_INVALID_ARG_TYPE というコードです。メッセージのテンプレートはマイナーバージョンで改善されることがありますが、err.code === 'ERR_INVALID_ARG_TYPE' によるチェックが壊れることはありません。

libuv や OS の操作に由来するシステムエラーには、追加のプロパティが付きます。errno(数値エラーコード)、syscall(失敗したシステムコール名)、path(関連するファイルパス)、dest(rename 操作の移動先)です。エラーメッセージをパースするよりも、こういった構造化されたデータの方がプログラムでの処理にはるかに有用です。

Web Platform API の統合

モダンな Node.js における大きな変化のひとつが、Web Platform API の採用です。第2回で見たように、ブートストラップチェーンは exposed-wildcard.jsexposed-window-or-worker.js を実行してこれらの API をグローバルに登録します。

API ソース 標準仕様
URL, URLSearchParams internal/url WHATWG URL
EventTarget, Event internal/event_target DOM Events
AbortController, AbortSignal internal/abort_controller DOM Abort
TextEncoder, TextDecoder internal/encoding Encoding
structuredClone internal/structured_clone HTML
fetch, Request, Response, Headers deps/undici/ WHATWG Fetch
WebSocket deps/undici/ HTML WebSocket
ReadableStream, WritableStream, TransformStream internal/webstreams/ WHATWG Streams
crypto.subtle internal/crypto/webcrypto Web Crypto
Blob, File internal/blob, internal/file File API
BroadcastChannel internal/worker/broadcast_channel HTML
Performance, PerformanceObserver internal/perf/ Performance Timeline
console internal/console/global Console
DOMException internal/per_context/domexception WebIDL

登録には2つのパターンがあります。exposeInterface() はクラスを globalThis に即座にアタッチします。一方 exposeLazyInterfaces() はプロパティディスクリプタを利用し、初めてアクセスされたときにモジュールをコンパイルします。これにより、TextEncoder が実際に使われるまでそのコンパイルコストを払わずに済みます。

fetch の実装は特筆に値します。完全に JavaScript で書かれた高性能 HTTP クライアント undici を使用しており、deps/undici/ にベンダリングされています。つまり Node.js の fetch() は、http.request() が使う C++ の HTTP パーサー(llhttp)とは別のスタックを経由しています。

graph LR
    subgraph "Two HTTP stacks"
        HTTP_OLD["http.request() / http.createServer()"] --> LLHTTP["llhttp (C)<br/>deps/llhttp/"]
        FETCH["fetch() / WebSocket"] --> UNDICI["undici (JS)<br/>deps/undici/"]
    end
    
    LLHTTP --> UV["libuv TCP"]
    UNDICI --> UV

process オブジェクトと実行前の初期化

ブートストラップと StartExecution のディスパッチ(第2回)の間に、pre_execution.js が実行され、プロセスを実行モードに合わせた状態に整えます。具体的には次の処理が含まれます。

  • process.env のセットアップ(プレーンな JS オブジェクトではなく C++ のプロキシオブジェクトが背後にある)
  • シグナルハンドラーの設定(SIGINTSIGTERM など)
  • uncaughtExceptionunhandledRejection のハンドリング設定
  • CJS または ESM モジュールローダーの初期化
  • スナップショットのデシリアライズコールバックの実行
  • NODE_V8_COVERAGE が設定されている場合のカバレッジフックの適用

C++ のオプションシステムは階層構造になっており、src/node_options.h で定義されています。

graph TD
    PP["PerProcessOptions<br/>--v8-pool-size, --title,<br/>--max-old-space-size"] --> PI["PerIsolateOptions<br/>--harmony-*, V8 flags,<br/>--build-snapshot"]
    PI --> ENV_OPT["EnvironmentOptions<br/>--require, --import,<br/>--loader, --conditions,<br/>--watch, --test"]
    ENV_OPT --> DBG["DebugOptions<br/>--inspect, --inspect-brk,<br/>--inspect-port"]

この階層は V8 の組み込みモデルを反映しています。V8 のフラグのように Isolate 生成前に設定しなければならないオプションはプロセス単位、Isolate 単位で異なるものはその次の層、そして Isolate を共有する Worker スレッドにとって重要な環境固有のオプションはさらにその下の層に置かれています。

スナップショットとシングル実行ファイルアプリケーション

V8 ヒープスナップショットは Node.js の起動高速化における主要な手段です。src/node_snapshotable.cc のスナップショットシステムは、ブートストラップ後の V8 ヒープ状態をキャプチャし、バイナリデータにシリアライズして Node.js バイナリに埋め込みます。

flowchart TD
    subgraph "Build Time"
        MKSNAPSHOT["node_mksnapshot"] --> BOOT["Run bootstrap scripts"]
        BOOT --> SERIALIZE["V8 SnapshotCreator<br/>Serialize heap"]
        SERIALIZE --> EMBED["Embed in binary<br/>as static data"]
    end
    
    subgraph "Runtime (normal)"
        START["node app.js"] --> DESER["Deserialize snapshot<br/>Skip bootstrap scripts"]
        DESER --> READY["Environment ready<br/>~30ms saved"]
    end
    
    subgraph "Runtime (--build-snapshot)"
        USER_SNAP["node --build-snapshot entry.js"] --> RUN["Run user entry script"]
        RUN --> CAPTURE["Capture heap state<br/>Including user code"]
        CAPTURE --> BLOB["Write snapshot blob"]
    end

--build-snapshot フラグを使えば、このスナップショット機能をユーザーランドのコードにも適用できます。アプリケーションの初期化コードを実行してヒープ状態をキャプチャしておけば、起動時にそれを即座に読み込めます。コールドスタートの速度が重要なサーバーレス関数では特に効果的です。

Single Executable Applications(SEA)はさらに一歩進んだ仕組みです。--experimental-sea-config を使うと、JavaScript アプリケーションと依存パッケージ、そして任意でスナップショットを Node.js バイナリにまとめ、外部依存のないスタンドアロン実行ファイルを生成できます。

SEA システムはスナップショットのインフラストラクチャを活用しています。StartExecution() は通常のディスパッチテーブルの前に sea::IsSingleExecutable() を確認し、sea::MaybeLoadSingleExecutableApplication() が埋め込まれたアプリケーションを展開して実行します。

ヒント: 起動パフォーマンスを最大限に引き出したいなら、--build-snapshot と SEA を組み合わせましょう。アプリの初期化スナップショットを作成してからシングル実行ファイルに埋め込むことで、複雑なアプリでも 50ms 以下のコールドスタートを実現できます。

組み込みテストランナー

--test で起動する組み込みテストランナーは、StartExecution のディスパッチテーブル(第2回)にあるエントリーポイントのひとつです。internal/main/test_runner.js にディスパッチされ、テストの探索・実行・レポート出力を一括して管理します。

graph TD
    CLI["node --test"] --> DISPATCH["StartExecution()<br/>→ internal/main/test_runner"]
    DISPATCH --> DISCOVER["Test discovery<br/>Find **/*.test.{js,mjs,cjs}"]
    DISCOVER --> RUNNER["Test runner<br/>lib/internal/test_runner/runner.js"]
    RUNNER --> HARNESS["Test harness<br/>lib/internal/test_runner/harness.js"]
    HARNESS --> TAP["TAP reporter<br/>or spec reporter"]
    
    RUNNER --> PARALLEL["Parallel execution<br/>Via child processes"]
    RUNNER --> WATCH_MODE["--test --watch<br/>Re-run on changes"]

テストランナーは他のテストフレームワークでもおなじみの describe() / it() / test() API を提供します。内部では Test オブジェクトのツリーを構築し、並行実行を管理しながら、デフォルトで TAP(Test Anything Protocol)形式の出力を生成します。テストは独立したプロセスとして実行され、結果は IPC 経由で親プロセスにストリームバックされます。

またテストランナーは、第4回で紹介したモジュールフックシステムとも連携します。--experimental-test-module-mocks フラグを使うと、テスト実行中にモジュールの読み込みをインターセプトするカスタムの resolve/load フックが登録され、モジュールのモック化が可能になります。

全体像をつなぐ

6回にわたるシリーズを通じて、Node.js のアーキテクチャ全体を追ってきました。

  1. 第1回 では全体の地図を描きました。ディレクトリ構造、C++ と JavaScript の二層アーキテクチャ、依存関係、ビルドシステムです。
  2. 第2回 では main() からブートストラップを経てイベントループに至る起動フローを追いました。
  3. 第3回 では C++ と JavaScript の2つの世界をつなぐブリッジの仕組みを明らかにしました。
  4. 第4回 では CJS・ESM・カスタマイズフックを通じてユーザーコードが読み込まれる過程を解説しました。
  5. 第5回 では I/O の実際の動作、すなわちストリーム・ハンドル・タイマー・マイクロタスクを見ていきました。
  6. 本記事 では、これらすべてを結びつける横断的な仕組みを取り上げました。

シリーズ全体を貫く共通テーマは「レイヤリング」です。Node.js は libuv と V8 をラップする C++ コアを持ち、その上に C++ コアをラップする JavaScript 標準ライブラリがあり、さらにその上でユーザーコードを読み込むモジュールシステムが動いています。各レイヤーには明確な責務と境界があります。パーミッションモデルは C++ レイヤーでアクセスを検査し、エラーシステムは JavaScript レイヤーで安定した契約を提供します。Web API はブートストラップ時に公開されます。スナップショットシステムは、これらすべてのレイヤーが初期化された後の状態をキャプチャして起動を最適化します。

これらのレイヤーと、それらをつなぐコードを理解することが第一歩です。Node.js へのコントリビュートや深いランタイム問題のデバッグ、プラットフォームと密に連携するツールの構築に役立ちます。