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 では DenoInNpmPackageChecker と NpmResolver<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 によってゲーティングされています。ブートストラップ時に BootstrapOptions の unstableFeatures 配列を参照し、有効になっていない機能の API は Deno オブジェクト上で undefined のままになります。
ヒント: JS ファイルの数字プレフィックス(
01_、02_、30_、90_、99_)はロード順を制御するものです。90_deno_ns.jsはほかのすべてからインポートし、99_main.jsは90_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 パスのどちらからでもデータをフラッシュできるようになっています。先に実行された側が値を取り出すことで、二重フラッシュを防いでいます。
ヒント: 中間型
LibMainWorker(deno_lib由来)があるおかげで、スタンドアロンバイナリは CLI への依存を持たずに同じ Worker ロジックを使えます。第 1 回で触れた「抽出パターン」のまた一つの例です。
次回予告
これで Deno Worker の完全なライフサイクルが見えてきました。ジェネリクスを使ったサービスオプションによる生成、Rust-V8-JavaScript の境界をまたぐブートストラップ、拡張モジュールからの名前空間の組み立て、op 層でのパーミッション適用、すべての流れをたどりました。最終回では、fmt・lint・test・compile などを動かす cli/tools/ ディレクトリを中心とした統合ツールチェーン、ext/node/ の深い Node.js 互換レイヤー、LSP アーキテクチャ、そして npm 統合を探っていきます。