Read OSS

Worker の内側:Bootstrap、Deno 名前空間、パーミッションシステム

上級

前提知識

  • 第 1〜3 回の記事
  • V8 isolate とコンテキストの基本知識

Worker の内側:Bootstrap、Deno 名前空間、パーミッションシステム

これまでの記事では、CLI のディスパッチ(第 1 回)、拡張システム(第 2 回)、モジュールの読み込み(第 3 回)を見てきました。今回はいよいよ Deno ランタイムの核心部分、MainWorker に踏み込みます。これは V8 isolate を所有し、イベントループを駆動し、ユーザーコードが触れる Deno.* API を提供する struct です。本記事では、WorkerOptions を経た生成の流れ、Rust と JavaScript の境界をまたぐブートストラップシーケンスを解説します。さらに、90_deno_ns.js が数十の拡張モジュールから Deno グローバルを組み立てる仕組みと、「デフォルトセキュア」を実現する 8 種類のパーミッションシステムを追っていきます。

MainWorker の生成と WorkerOptions

MainWorker struct は見た目よりずっとコンパクトです。JsRuntime をベースに、ライフサイクルイベント用のいくつかの V8 関数ハンドルを持つだけです。

pub struct MainWorker {
  pub js_runtime: JsRuntime,
  should_break_on_first_statement: bool,
  should_wait_for_inspector_session: bool,
  exit_code: ExitCode,
  bootstrap_fn_global: Option<v8::Global<v8::Function>>,
  dispatch_load_event_fn_global: v8::Global<v8::Function>,
  dispatch_beforeunload_event_fn_global: v8::Global<v8::Function>,
  dispatch_unload_event_fn_global: v8::Global<v8::Function>,
  dispatch_process_beforeexit_event_fn_global: v8::Global<v8::Function>,
  dispatch_process_exit_event_fn_global: v8::Global<v8::Function>,
}

v8::Global<v8::Function> のハンドルは、初期化時に取り出されてあとから呼び出せるよう保持された JavaScript 関数へのポインタです。bootstrap 関数はブートストラップのフェーズで Option::take によって消費され、イベントディスパッチ用の関数は Worker の生存期間を通じて保持されます。

生成の際は WorkerServiceOptions でパラメータを受け取ります。この struct は 3 つのジェネリック型パラメータを持っています。

pub struct WorkerServiceOptions<
  TInNpmPackageChecker: InNpmPackageChecker,
  TNpmPackageFolderResolver: NpmPackageFolderResolver,
  TExtNodeSys: ExtNodeSys,
> {
  pub module_loader: Rc<dyn ModuleLoader>,
  pub permissions: PermissionsContainer,
  pub fs: Arc<dyn FileSystem>,
  pub blob_store: Arc<BlobStore>,
  pub node_services: Option<NodeExtInitServices<...>>,
  pub v8_code_cache: Option<Arc<dyn CodeCache>>,
  // ...
}

このジェネリクスのおかげで、CLI では DenoInNpmPackageCheckerNpmResolver<RealSys> を使い、スタンドアロンバイナリでは仮想ファイルシステムを使った独自の実装を使い、テストではすべてをモックにする、という柔軟な構成が可能になっています。

classDiagram
    class MainWorker {
        +js_runtime: JsRuntime
        +bootstrap(options)
        +execute_main_module(url)
        +run_event_loop()
        +dispatch_load_event()
    }
    class WorkerServiceOptions~T1,T2,T3~ {
        +module_loader: Rc~dyn ModuleLoader~
        +permissions: PermissionsContainer
        +fs: Arc~dyn FileSystem~
        +node_services: Option~NodeExtInitServices~
    }
    class WorkerOptions {
        +bootstrap: BootstrapOptions
        +extensions: Vec~Extension~
        +startup_snapshot: Option~&[u8]~
        +unconfigured_runtime: Option~UnconfiguredRuntime~
    }
    MainWorker <.. WorkerServiceOptions : created from
    MainWorker <.. WorkerOptions : created from

ブートストラップシーケンス:Rust → V8 → JavaScript

bootstrap() メソッドが、Rust が初めて JavaScript を呼び出す場所です。

pub fn bootstrap(&mut self, options: BootstrapOptions) {
  {
    let op_state = self.js_runtime.op_state();
    let mut state = op_state.borrow_mut();
    state.put(options.clone());
    if let Some((fd, serialization)) = options.node_ipc_init {
      state.put(deno_node::ChildPipeFd(fd, serialization));
    }
  }

  deno_core::scope!(scope, &mut self.js_runtime);
  v8::tc_scope!(scope, scope);
  let args = options.as_v8(scope);
  let bootstrap_fn = self.bootstrap_fn_global.take().unwrap();
  let bootstrap_fn = v8::Local::new(scope, bootstrap_fn);
  let undefined = v8::undefined(scope);
  bootstrap_fn.call(scope, undefined.into(), &[args]);
}

まずブートストラップオプションが OpState に格納され(op からアクセス可能になります)、次に V8 の値にシリアライズされて JavaScript の bootstrapMainRuntime() 関数に直接渡されます。この関数はスナップショット作成時に 99_main.js から取り出され、v8::Global<v8::Function> として保持されています。

sequenceDiagram
    participant Rust as MainWorker (Rust)
    participant OpState as OpState
    participant V8 as V8 Scope
    participant JS as 99_main.js

    Rust->>OpState: Put BootstrapOptions
    Rust->>V8: Create scope
    Rust->>V8: options.as_v8(scope)
    Note over V8: Serialize Deno version,<br/>location, unstable features,<br/>inspect flag, etc.
    Rust->>V8: bootstrap_fn.call(scope, args)
    V8->>JS: bootstrapMainRuntime(runtimeOptions)
    JS->>JS: Configure console for serve mode
    JS->>JS: Register main module handler
    JS->>JS: Set up globalThis properties
    JS->>JS: Configure error formatting
    JS->>JS: Set hasBootstrapped = true
    JS-->>V8: return
    V8-->>Rust: bootstrap complete

bootstrap 関数への take().unwrap() は意図的なものです。この関数は一度しか呼ばれてはならないためです。二度呼び出そうとすると、None によってパニックが起き、JavaScript 側から "Worker runtime already bootstrapped" というエラーが返されます。

Deno 名前空間の組み立て

runtime/js/90_deno_ns.js は、各拡張モジュールからインポートしながら Deno グローバル名前空間を構築します。

import * as fs from "ext:deno_fs/30_fs.js";
import * as net from "ext:deno_net/01_net.js";
import * as process from "ext:deno_process/40_process.js";
import * as permissions from "ext:runtime/10_permissions.js";
import * as kv from "ext:deno_kv/01_db.ts";
import * as telemetry from "ext:deno_telemetry/telemetry.ts";
// ... 30+ imports

const denoNs = {
  writeFileSync: fs.writeFileSync,
  writeFile: fs.writeFile,
  readTextFile: fs.readTextFile,
  readFile: fs.readFile,
  watchFs: fsEvents.watchFs,
  chmod: fs.chmod,
  cwd: fs.cwd,
  // ... 100+ API bindings
};

ext: プレフィックスのインポートは、拡張(第 2 回参照)にバンドルされた JavaScript ファイルを参照しています。denoNs オブジェクトはフラットな名前空間で、プロトタイプチェーンもクラスもなく、関数参照と遅延ゲッターで構成されています。

graph TD
    subgraph "Extension Sources"
        FS["ext:deno_fs/30_fs.js"]
        NET["ext:deno_net/01_net.js"]
        PROC["ext:deno_process/40_process.js"]
        KV["ext:deno_kv/01_db.ts"]
        CRON["ext:deno_cron/01_cron.ts"]
        FETCH["ext:deno_fetch/22_http_client.js"]
        PERM["ext:runtime/10_permissions.js"]
    end

    subgraph "Deno Namespace"
        DNS["Deno.readFile()<br/>Deno.writeFile()<br/>Deno.cwd()"]
        DNN["Deno.connect()<br/>Deno.listen()"]
        DNP["Deno.run()<br/>Deno.Command"]
        DNK["Deno.openKv()"]
        DNC["Deno.cron()"]
        DNH["Deno.createHttpClient()"]
        DNPERM["Deno.permissions"]
    end

    FS --> DNS
    NET --> DNN
    PROC --> DNP
    KV --> DNK
    CRON --> DNC
    FETCH --> DNH
    PERM --> DNPERM

unstable な API は機能 ID によってゲーティングされています。ブートストラップ時に BootstrapOptionsunstableFeatures 配列を参照し、有効になっていない機能の API は Deno オブジェクト上で undefined のままになります。

ヒント: JS ファイルの数字プレフィックス(01_02_30_90_99_)はロード順を制御するものです。90_deno_ns.js はほかのすべてからインポートし、99_main.js90_deno_ns.js からインポートします。この規則は拡張における ES module サポートが整う前から存在しており、可読性のために今も維持されています。

パーミッションシステム

Deno の「デフォルトセキュア」モデルは、runtime/permissions/lib.rs で定義された 8 種類のパーミッションシステムによって実現されています。これらは op がアクセスしうるあらゆるシステムリソースをカバーしています。

パーミッション フラグ 保護対象
Read --allow-read ファイル・ディレクトリの読み取り
Write --allow-write ファイル・ディレクトリへの書き込み
Net --allow-net ネットワーク接続
Env --allow-env 環境変数へのアクセス
Run --allow-run サブプロセスの実行
FFI --allow-ffi Foreign Function Interface
Sys --allow-sys システム情報の照会
Import --allow-import リモートモジュールのインポート

PermissionFlags struct を見ると、3 段階の設計になっていることがわかります。

pub struct PermissionFlags {
  pub allow_all: bool,
  pub allow_env: Option<Vec<String>>,
  pub deny_env: Option<Vec<String>>,
  pub ignore_env: Option<Vec<String>>,
  pub allow_read: Option<Vec<String>>,
  pub deny_read: Option<Vec<String>>,
  pub ignore_read: Option<Vec<String>>,
  // ... similar for net, run, ffi, sys, write
}

deny_* フラグは allow_* より優先されます。たとえば全ネットワークアクセスを許可しながら特定のホストだけを拒否できます。ignore_* フラグは特定のリソースに対するインタラクティブなプロンプトを抑制するもので、CI 環境で役立ちます。

flowchart TD
    OP["Op requests permission<br/>e.g., read /etc/passwd"]
    DENY{Denied by<br/>--deny-read?}
    DENY -->|Yes| BLOCKED["❌ PermissionDenied"]
    DENY -->|No| ALLOW{Allowed by<br/>--allow-read?}
    ALLOW -->|Yes| GRANTED["✅ Granted"]
    ALLOW -->|No| IGNORE{Ignored by<br/>--ignore-read?}
    IGNORE -->|Yes| BLOCKED
    IGNORE -->|No| BROKER{Permission<br/>broker active?}
    BROKER -->|Yes| IPC["Ask broker via IPC"]
    BROKER -->|No| TTY{Interactive<br/>terminal?}
    TTY -->|Yes| PROMPT["🔐 Prompt user<br/>Allow? [y/n/A]"]
    TTY -->|No| BLOCKED
    PROMPT -->|Allow| GRANTED
    PROMPT -->|Allow All| GRANT_ALL["✅ Grant for type"]
    PROMPT -->|Deny| BLOCKED
    IPC -->|Allow| GRANTED
    IPC -->|Deny| BLOCKED

パーミッションチェックは Rust の op 境界で行われます。第 2 回で追ったのと同じ op の層です。すべてのファイルシステム op はディスクにアクセスする前に state.borrow::<PermissionsContainer>() を呼んでパーミッションを確認します。これにより、どの呼び出し経路を通ってもパーミッションチェックを回避できない構造になっています。

パーミッションライブラリの OtelAuditFn 型のヒントは、もう一つの機能を示しています。OpenTelemetry による監査ログです。OTEL が設定されている場合、パーミッションチェックの結果(許可・拒否を問わず)がすべてテレメトリイベントとして送出され、プログラムが何にアクセスしようとしたかの監査ログが残ります。

パーミッションブローカー:実験的な IPC ベースの管理

maybe_setup_permission_broker() 関数からは、実験的な機能の存在がわかります。IPC を通じてパーミッションの判断を外部プロセスに委譲する仕組みです。

fn maybe_setup_permission_broker() {
  let Ok(socket_path) = std::env::var("DENO_PERMISSION_BROKER_PATH") else {
    return;
  };
  log::warn!("{} Permission broker is an experimental feature", 
    colors::yellow("Warning"));
  let broker = PermissionBroker::new(socket_path);
  deno_runtime::deno_permissions::broker::set_broker(broker);
}

環境変数 DENO_PERMISSION_BROKER_PATH が設定されている場合、Deno は Unix ドメインソケットに接続し、パーミッションリクエストを外部プロセスに送ります。これにより、IDE やオーケストレーターなどの親プロセスが複数の Deno プロセスに対してパーミッションを一元管理するようなシナリオが実現できます。

sequenceDiagram
    participant Deno as Deno Process
    participant Broker as Permission Broker
    
    Note over Deno: DENO_PERMISSION_BROKER_PATH set
    Deno->>Broker: Connect via Unix socket
    Deno->>Deno: Op requests net permission
    Deno->>Broker: Check: net, "example.com:443"
    Broker->>Broker: Apply policy
    Broker-->>Deno: Allow / Deny
    Deno->>Deno: Grant or reject op

ブローカーはパーミッションフローの中でインタラクティブなプロンプトより前に確認されます。ブローカーが有効な場合、パーミッションの判断はすべてブローカーが行います。

CliMainWorker:カバレッジ、プロファイリング、HMR

runtime/MainWorker は汎用的な Worker です。CLI ではこれを CliMainWorker でラップし、CLI 固有の機能を追加しています。

pub struct CliMainWorkerOptions {
  pub create_hmr_runner: Option<CreateHmrRunnerCb>,
  pub maybe_coverage_dir: Option<PathBuf>,
  pub maybe_cpu_prof_config: Option<CpuProfilerConfig>,
  pub default_npm_caching_strategy: NpmCachingStrategy,
  pub needs_test_modules: bool,
}

pub struct CliMainWorker {
  worker: LibMainWorker,
  shared: Arc<SharedState>,
}

run() メソッドは、モジュールの実行をカバレッジ収集・CPU プロファイリング・HMR(ホットモジュールリプレースメント)でラップしています。これらは Rc<RefCell<Option<...>>> でラップされており、通常の終了パスと Deno.exit() の op パスのどちらからでもデータをフラッシュできるようになっています。先に実行された側が値を取り出すことで、二重フラッシュを防いでいます。

ヒント: 中間型 LibMainWorkerdeno_lib 由来)があるおかげで、スタンドアロンバイナリは CLI への依存を持たずに同じ Worker ロジックを使えます。第 1 回で触れた「抽出パターン」のまた一つの例です。

次回予告

これで Deno Worker の完全なライフサイクルが見えてきました。ジェネリクスを使ったサービスオプションによる生成、Rust-V8-JavaScript の境界をまたぐブートストラップ、拡張モジュールからの名前空間の組み立て、op 層でのパーミッション適用、すべての流れをたどりました。最終回では、fmtlinttestcompile などを動かす cli/tools/ ディレクトリを中心とした統合ツールチェーン、ext/node/ の深い Node.js 互換レイヤー、LSP アーキテクチャ、そして npm 統合を探っていきます。