🚀 VS Codeでを入手しましょう!

Extension開発にWebAssemblyを利用する

2024年5月8日 Dirk Bäumer 著

Visual Studio Codeは、WebAssembly Execution Engine拡張機能を通じて、WASMバイナリの実行をサポートしています。主なユースケースは、C/C++またはRustで記述されたプログラムをWebAssemblyにコンパイルし、これらのプログラムをVS Codeで直接実行することです。注目すべき例は、Visual Studio Code for Educationであり、このサポートを利用してWeb用VS CodeでPythonインタープリターを実行しています。このブログ記事では、その実装方法について詳しく解説しています。

2024年1月、Bytecode AllianceはWASI 0.2プレビューを公開しました。WASI 0.2プレビューの重要なテクノロジーは、Component Modelです。WebAssembly Component Modelは、インターフェース、データ型、およびモジュール構成を標準化することにより、WebAssemblyコンポーネントとホスト環境間の相互作用を効率化します。この標準化は、WIT(WASM Interface Type)ファイルの使用を通じて促進されます。WITファイルは、JavaScript/TypeScript拡張機能(ホスト)と、RustやC/C++などの別の言語でコーディングされた計算を実行するWebAssemblyコンポーネント間の相互作用を記述するのに役立ちます。

このブログ記事では、開発者がコンポーネントモデルを活用してWebAssemblyライブラリを拡張機能に統合する方法について概説します。ここでは、3つのユースケースに焦点を当てます。(a) WebAssemblyを使用してライブラリを実装し、JavaScript/TypeScriptの拡張機能コードからそれを呼び出す、(b) WebAssemblyコードからVS Code APIを呼び出す、(c) リソースを使用して、WebAssemblyまたはTypeScriptコードのいずれかでステートフルオブジェクトをカプセル化および管理する方法を示す。

これらの例を実行するには、VS CodeおよびNodeJSに加えて、次のツールの最新バージョンがインストールされている必要があります。Rustコンパイラツールチェーンwasm-tools、およびwit-bindgen

また、この記事に関する貴重なフィードバックをいただいたFastlyのL. Pereira氏とLuke Wagner氏に感謝申し上げます。

Rustでの電卓

最初の例では、開発者がRustで記述されたライブラリをVS Code拡張機能に統合する方法を示します。前述のように、コンポーネントはWITファイルを使用して記述されます。この例では、ライブラリは加算、減算、乗算、除算などの単純な演算を実行します。対応するWITファイルを以下に示します。

package vscode:example;

interface types {
	record operands {
		left: u32,
		right: u32
	}

	variant operation {
		add(operands),
		sub(operands),
		mul(operands),
		div(operands)
	}
}
world calculator {
	use types.{ operation };

	export calc: func(o: operation) -> u32;
}

Rustツールであるwit-bindgenは、電卓のRustバインディングを生成するために利用されます。このツールには2つの使用方法があります。

  • 実装ファイル内でバインディングを直接生成する手続き型マクロとして。この方法は標準的ですが、生成されたバインディングコードを検査できないという欠点があります。

  • ディスク上にバインディングファイルを作成するコマンドラインツールとして。このアプローチは、以下のリソースの例のVS Code拡張機能サンプルリポジトリにあるコードで例示されています。

手続き型マクロとしてwit-bindgenツールを使用する対応するRustファイルは、次のようになります。

// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
	// the name of the world in the `*.wit` input file
	world: "calculator",
});

ただし、コマンドcargo build --target wasm32-unknown-unknownを使用してRustファイルをWebAssemblyにコンパイルすると、エクスポートされたcalc関数の実装が欠落しているため、コンパイルエラーが発生します。以下は、calc関数の簡単な実装です。

// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
	// the name of the world in the `*.wit` input file
	world: "calculator",
});

struct Calculator;

impl Guest for Calculator {

    fn calc(op: Operation) -> u32 {
		match op {
			Operation::Add(operands) => operands.left + operands.right,
			Operation::Sub(operands) => operands.left - operands.right,
			Operation::Mul(operands) => operands.left * operands.right,
			Operation::Div(operands) => operands.left / operands.right,
		}
	}
}

// Export the Calculator to the extension code.
export!(Calculator);

ファイルの最後にあるexport!(Calculator);ステートメントは、拡張機能がAPIを呼び出せるように、WebAssemblyコードからCalculatorをエクスポートします。

wit2tsツールは、VS Code拡張機能内のWebAssemblyコードと対話するために必要なTypeScriptバインディングを生成するために使用されます。このツールは、VS CodeチームがVS Code拡張機能アーキテクチャの特定の要件を満たすために開発しました。主な理由は次のとおりです。

  • VS Code APIは、拡張機能ホストワーカー内でのみアクセス可能です。拡張機能ホストワーカーから派生した追加のワーカーは、VS Code APIにアクセスできません。これは、各ワーカーが通常、ほとんどすべてのランタイムAPIにアクセスできるNodeJSやブラウザーなどの環境とは対照的です。
  • 複数の拡張機能が同じ拡張機能ホストワーカーを共有します。拡張機能は、そのワーカーで長時間実行される同期計算を実行することを避ける必要があります。

これらのアーキテクチャ上の要件は、VS Code用WASIプレビュー1を実装したときからすでに存在していました。ただし、最初の実装は手動で記述されました。コンポーネントモデルのより広範な採用を予測して、コンポーネントとそのVS Code固有のホスト実装の統合を容易にするツールを開発しました。

コマンドwit2ts --outDir ./src ./witは、srcフォルダーにcalculator.tsファイルを生成します。これには、WebAssemblyコード用のTypeScriptバインディングが含まれています。これらのバインディングを利用する簡単な拡張機能は、次のようになります。

import * as vscode from 'vscode';
import { WasmContext, Memory } from '@vscode/wasm-component-model';

// Import the code generated by wit2ts
import { calculator, Types } from './calculator';

export async function activate(context: vscode.ExtensionContext): Promise<void> {
  // The channel for printing the result.
  const channel = vscode.window.createOutputChannel('Calculator');
  context.subscriptions.push(channel);

  // Load the Wasm module
  const filename = vscode.Uri.joinPath(
    context.extensionUri,
    'target',
    'wasm32-unknown-unknown',
    'debug',
    'calculator.wasm'
  );
  const bits = await vscode.workspace.fs.readFile(filename);
  const module = await WebAssembly.compile(bits);

  // The context for the WASM module
  const wasmContext: WasmContext.Default = new WasmContext.Default();

  // Instantiate the module
  const instance = await WebAssembly.instantiate(module, {});
  // Bind the WASM memory to the context
  wasmContext.initialize(new Memory.Default(instance.exports));

  // Bind the TypeScript Api
  const api = calculator._.exports.bind(
    instance.exports as calculator._.Exports,
    wasmContext
  );

  context.subscriptions.push(
    vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
      channel.show();
      channel.appendLine('Running calculator example');
      const add = Types.Operation.Add({ left: 1, right: 2 });
      channel.appendLine(`Add ${api.calc(add)}`);
      const sub = Types.Operation.Sub({ left: 10, right: 8 });
      channel.appendLine(`Sub ${api.calc(sub)}`);
      const mul = Types.Operation.Mul({ left: 3, right: 7 });
      channel.appendLine(`Mul ${api.calc(mul)}`);
      const div = Types.Operation.Div({ left: 10, right: 2 });
      channel.appendLine(`Div ${api.calc(div)}`);
    })
  );
}

上記のコードをコンパイルしてWeb用VS Codeで実行すると、Calculatorチャネルに次の出力が生成されます。

この例の完全なソースコードは、VS Code拡張機能サンプルリポジトリにあります。

@vscode/wasm-component-model の内部

wit2tsツールによって生成されたソースコードを調べると、@vscode/wasm-component-model npmモジュールへの依存関係が明らかになります。このモジュールは、コンポーネントモデルのcanonical ABIのVS Code実装として機能し、対応するPythonコードから着想を得ています。このブログ記事を理解するためにコンポーネントモデルの内部構造を理解する必要はありませんが、特にJavaScript/TypeScriptとWebAssemblyコード間でデータがどのように渡されるかに関して、その仕組みについて少し説明します。

wit-bindgenjcoなど、WITファイルのバインディングを生成する他のツールとは異なり、wit2tsはメタモデルを作成し、さまざまなユースケースでランタイムにバインディングを生成するために使用できます。この柔軟性により、VS Code内の拡張機能開発のアーキテクチャ要件を満たすことができます。このアプローチを使用することで、バインディングを「プロミス化」し、ワーカーでWebAssemblyコードを実行できるようにすることができます。このメカニズムを使用して、VS Code用のWASI 0.2プレビューを実装します。

バインディングを生成するときに、関数がcalculator._.imports.createのような名前(アンダースコアに注意)を使用して参照されていることに気付いたかもしれません。WITファイル内のシンボル(たとえば、importsという名前の型定義がある可能性があります)との名前の衝突を避けるために、API関数は_名前空間に配置されます。メタモデル自体は$名前空間に存在します。したがって、calculator.$.exports.calcは、エクスポートされたcalc関数のメタデータを表します。

上記の例では、calc関数に渡されるadd演算パラメーターは、演算コード、左側の値、および右側の値の3つのフィールドで構成されています。コンポーネントモデルのcanonical ABIによると、引数は値渡しされます。また、データがシリアル化され、WebAssembly関数に渡され、反対側でデシリアライズされる方法についても概説しています。このプロセスにより、JavaScriptヒープ上のオブジェクトと線形WebAssemblyメモリ内のオブジェクトの2つの演算オブジェクトが生成されます。次の図はこれを示しています。

Diagram illustrating how parameters are passed.

次の表に、使用可能なWIT型、VS Codeコンポーネントモデル実装でのJavaScriptオブジェクトへのマッピング、および使用される対応するTypeScript型を示します。

WIT JavaScript TypeScript
u8 number type u8 = number;
u16 number type u16 = number;
u32 number type u32 = number;
u64 bigint type u64 = bigint;
s8 number type s8 = number;
s16 number type s16 = number;
s32 number type s32 = number;
s64 bigint type s64 = bigint;
float32 number type float32 = number;
float64 number type float64 = number;
bool boolean boolean
string string string
char string[0] string
record object literal type declaration
list<T> [] Array<T>
tuple<T1, T2> [] [T1, T2]
enum string values string enum
flags number bigint
variant object literal discriminated union
option<T> variable ? and (T | undefined)
result<ok, err> Exception or object literal Exception or result type

コンポーネントモデルは、低レベル(Cスタイル)ポインターをサポートしていないことに注意することが重要です。そのため、オブジェクトグラフや再帰的なデータ構造を渡すことはできません。この点で、JSONと同じ制限を共有しています。データコピーを最小限に抑えるために、コンポーネントモデルはリソースの概念を導入しています。これについては、このブログ記事の今後のセクションで詳しく説明します。

jcoプロジェクトも、typeコマンドを使用してWebAssemblyコンポーネント用のJavaScript/TypeScriptバインディングの生成をサポートしています。前述のように、VS Codeの特定のニーズを満たすために独自のツールを開発しました。ただし、可能な限りツール間の整合性を確保するために、jcoチームと隔週で会議を開催しています。基本的な要件は、両方のツールがWITデータ型に同じJavaScriptおよびTypeScript表現を使用する必要があることです。また、2つのツール間でコードを共有する可能性も検討しています。

WebAssemblyコードからTypeScriptを呼び出す

WITファイルは、ホスト(VS Code拡張機能)とWebAssemblyコード間の相互作用を記述し、双方向通信を容易にします。この例では、この機能により、WebAssemblyコードがアクティビティのトレースをログに記録できます。これを有効にするには、WITファイルを次のように変更します。

world calculator {

	/// ....

	/// A log function implemented on the host side.
	import log: func(msg: string);

	/// ...
}

Rust側では、ログ関数を呼び出すことができるようになりました。

fn calc(op: Operation) -> u32 {
	log(&format!("Starting calculation: {:?}", op));
	let result = match op {
		// ...
	};
	log(&format!("Finished calculation: {:?}", op));
	result
}

TypeScript側では、拡張機能開発者に必要なアクションは、ログ関数の実装を提供することだけです。VS Codeコンポーネントモデルは、必要なバインディングの生成を容易にし、WebAssemblyインスタンスへのインポートとして渡されます。

export async function activate(context: vscode.ExtensionContext): Promise<void> {
  // ...

  // The channel for printing the log.
  const log = vscode.window.createOutputChannel('Calculator - Log', { log: true });
  context.subscriptions.push(log);

  // The implementation of the log function that is called from WASM
  const service: calculator.Imports = {
    log: (msg: string) => {
      log.info(msg);
    }
  };

  // Create the bindings to import the log function into the WASM module
  const imports = calculator._.imports.create(service, wasmContext);
  // Instantiate the module
  const instance = await WebAssembly.instantiate(module, imports);

  // ...
}

最初の例と比較して、WebAssembly.instantiate呼び出しには、calculator._.imports.create(service, wasmContext)の結果が2番目の引数として含まれるようになりました。このimports.create呼び出しは、サービス実装から低レベルのWASMバインディングを生成します。最初の例では、インポートは不要だったため、空のオブジェクトリテラルを渡しました。今回は、デバッガーでVS Codeデスクトップ環境で拡張機能を実行します。Connor Peet氏の優れた仕事のおかげで、Rustコードにブレークポイントを設定し、VS Codeデバッガーを使用してステップ実行することが可能になりました。

Component Model Resourcesの使用

WebAssemblyコンポーネントモデルは、状態をカプセル化および管理するための標準化されたメカニズムを提供するリソースの概念を導入しています。この状態は、呼び出し境界の一方の側(たとえば、TypeScriptコード内)で管理され、もう一方の側(たとえば、WebAssemblyコード内)でアクセスおよび操作されます。リソースは、WASIプレビュー0.2 APIで広範囲に使用されており、ファイル記述子が典型的な例です。このセットアップでは、状態は拡張機能ホストによって管理され、WebAssemblyコードによってアクセスおよび操作されます。

リソースは、状態がWebAssemblyコードによって管理され、拡張機能コードによってアクセスおよび操作される逆方向にも機能できます。このアプローチは、VS CodeがWebAssemblyでステートフルサービスを実装し、TypeScript側からアクセスする場合に特に有益です。以下の例では、逆ポーランド記法をサポートする電卓を実装するリソースを定義します。Hewlett-Packardのハンドヘルド電卓で使用されているものと同様です。

// wit/calculator.wit
package vscode:example;

interface types {

	enum operation {
		add,
		sub,
		mul,
		div
	}

	resource engine {
		constructor();
		push-operand: func(operand: u32);
		push-operation: func(operation: operation);
		execute: func() -> u32;
	}
}
world calculator {
	export types;
}

以下は、Rustでの電卓リソースの簡単な実装です。

impl EngineImpl {
	fn new() -> Self {
		EngineImpl {
			left: None,
			right: None,
		}
	}

	fn push_operand(&mut self, operand: u32) {
		if self.left == None {
			self.left = Some(operand);
		} else {
			self.right = Some(operand);
		}
	}

	fn push_operation(&mut self, operation: Operation) {
        let left = self.left.unwrap();
        let right = self.right.unwrap();
        self.left = Some(match operation {
			Operation::Add => left + right,
			Operation::Sub => left - right,
			Operation::Mul => left * right,
			Operation::Div => left / right,
		});
	}

	fn execute(&mut self) -> u32 {
		self.left.unwrap()
	}
}

TypeScriptコードでは、以前と同じ方法でエクスポートをバインドします。唯一の違いは、バインディングプロセスによって、WebAssemblyコード内でcalculatorリソースをインスタンス化および管理するために使用されるプロキシクラスが提供されることです。

// Bind the JavaScript Api
const api = calculator._.exports.bind(
  instance.exports as calculator._.Exports,
  wasmContext
);

context.subscriptions.push(
  vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
    channel.show();
    channel.appendLine('Running calculator example');

    // Create a new calculator engine
    const calculator = new api.types.Engine();

    // Push some operands and operations
    calculator.pushOperand(10);
    calculator.pushOperand(20);
    calculator.pushOperation(Types.Operation.add);
    calculator.pushOperand(2);
    calculator.pushOperation(Types.Operation.mul);

    // Calculate the result
    const result = calculator.execute();
    channel.appendLine(`Result: ${result}`);
  })
);

対応するコマンドを実行すると、出力チャネルにResult: 60と出力されます。前述のように、リソースの状態は呼び出し境界の一方の側に存在し、ハンドルを使用して反対側からアクセスされます。リソースと対話するメソッドに渡される引数を除き、データコピーは発生しません。

Diagram illustrating how resources are accessed.

この例の完全なソースコードは、VS Code拡張機能サンプルリポジトリで入手できます。

RustからVS Code APIを直接使用する

コンポーネントモデルリソースは、WebAssemblyコンポーネントとホスト全体で状態をカプセル化および管理するために役立ちます。この機能により、リソースを利用してVS Code APIをWebAssemblyコードに標準的に公開することができます。このアプローチの利点は、拡張機能全体をWebAssemblyにコンパイルされる言語で作成できるという事実にあります。このアプローチを検討し始めており、以下はRustで記述された拡張機能のソースコードです。

use std::rc::Rc;

#[export_name = "activate"]
pub fn activate() -> vscode::Disposables {
	let mut disposables: vscode::Disposables = vscode::Disposables::new();

	// Create an output channel.
	let channel: Rc<vscode::OutputChannel> = Rc::new(vscode::window::create_output_channel("Rust Extension", Some("plaintext")));

	// Register a command handler
	let channel_clone = channel.clone();
	disposables.push(vscode::commands::register_command("testbed-component-model-vscode.run", move || {
		channel_clone.append_line("Open documents");

		// Print the URI of all open documents
		for document in vscode::workspace::text_documents() {
			channel.append_line(&format!("Document: {}", document.uri()));
		}
	}));
	return disposables;
}

#[export_name = "deactivate"]
pub fn deactivate() {
}

このコードは、TypeScriptで記述された拡張機能に似ていることに注意してください。

この調査は有望に見えますが、現時点では続行しないことにしました。主な理由は、WASMでの非同期サポートの欠如です。多くのVS Code APIは非同期であるため、WebAssemblyコードに直接プロキシすることは困難です。WebAssemblyコードを別のワーカーで実行し、WASIプレビュー1サポートで使用されているのと同じ同期メカニズムをWebAssemblyワーカーと拡張機能ホストワーカーの間で使用することができます。ただし、このアプローチは、同期API呼び出し中に予期しない動作を引き起こす可能性があります。これらの呼び出しは実際には非同期で実行されるためです。その結果、観測可能な状態は2つの同期呼び出し間で変化する可能性があります(たとえば、setX(5); getX();は5を返さない可能性があります)。

さらに、WASI 0.3プレビューのタイムフレームで完全な非同期サポートを導入する取り組みが進められています。Luke Wagner氏は、WASM I/O 2024で非同期サポートの現状に関する最新情報を提供しました。このサポートを待つことにしました。これにより、より完全でクリーンな実装が可能になるためです。

対応するWITファイル、Rustコード、およびTypeScriptコードに関心がある場合は、vscode-wasmリポジトリのrust-apiフォルダーにあります。

今後の展望

現在、WebAssemblyコードを拡張機能開発に活用できる分野をさらに網羅するフォローアップブログ記事を準備中です。主なトピックは次のとおりです。

  • WebAssemblyでの言語サーバーの作成。
  • 生成されたメタモデルを使用して、長時間実行されるWebAssemblyコードを別のワーカーに透過的にオフロードする。

VS Codeイディオム的なコンポーネントモデルの実装により、VS Code用のWASI 0.2プレビューの実装に向けた取り組みを継続します。

ありがとうございます。

DirkとVS Codeチーム

ハッピーコーディング!