WebAssembly für die Entwicklung von Erweiterungen nutzen
8. Mai 2024 von Dirk Bäumer
Visual Studio Code unterstützt die Ausführung von WASM-Binärdateien über die Erweiterung WebAssembly Execution Engine. Der primäre Anwendungsfall ist die Kompilierung von Programmen, die in C/C++ oder Rust geschrieben sind, nach WebAssembly und die anschließende Ausführung dieser Programme direkt in VS Code. Ein bemerkenswertes Beispiel ist Visual Studio Code for Education, das diese Unterstützung nutzt, um den Python-Interpreter in VS Code für das Web auszuführen. Dieser Blogbeitrag gibt detaillierte Einblicke in die Implementierung.
Im Januar 2024 startete die Bytecode Alliance die WASI 0.2 Preview. Eine Schlüsseltechnologie in der WASI 0.2 Preview ist das Component Model. Das WebAssembly Component Model vereinfacht die Interaktion zwischen WebAssembly-Komponenten und ihren Host-Umgebungen durch die Standardisierung von Schnittstellen, Datentypen und Modulkomposition. Diese Standardisierung wird durch die Verwendung einer WIT-Datei (WASM Interface Type) ermöglicht. WIT-Dateien helfen dabei, die Interaktionen zwischen einer JavaScript/TypeScript-Erweiterung (dem Host) und einer WebAssembly-Komponente zu beschreiben, die Berechnungen in einer anderen Sprache wie Rust oder C/C++ durchführt.
Dieser Blogbeitrag beschreibt, wie Entwickler das Component Model nutzen können, um eine WebAssembly-Bibliothek in ihre Erweiterung zu integrieren. Wir konzentrieren uns auf drei Anwendungsfälle: (a) Implementierung einer Bibliothek mit WebAssembly und deren Aufruf aus dem Erweiterungscode in JavaScript/TypeScript, (b) Aufruf der VS Code API aus WebAssembly-Code und (c) Demonstration, wie Ressourcen zur Kapselung und Verwaltung zustandsbehafteter Objekte in WebAssembly- oder TypeScript-Code verwendet werden.
Die Beispiele erfordern, dass Sie die neuesten Versionen der folgenden Tools neben VS Code und NodeJS installiert haben: Rust Compiler Toolchain, wasm-tools und wit-bindgen.
Ich möchte mich auch bei L. Pereira und Luke Wagner von Fastly für ihr wertvolles Feedback zu diesem Artikel bedanken.
Ein Taschenrechner in Rust
Im ersten Beispiel demonstrieren wir, wie ein Entwickler eine in Rust geschriebene Bibliothek in eine VS Code-Erweiterung integrieren kann. Wie bereits erwähnt, werden Komponenten mithilfe einer WIT-Datei beschrieben. In unserem Beispiel führt die Bibliothek einfache Operationen wie Addition, Subtraktion, Multiplikation und Division durch. Die entsprechende WIT-Datei ist unten dargestellt.
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;
}
Das Rust-Tool wit-bindgen wird verwendet, um ein Rust-Binding für den Rechner zu generieren. Es gibt zwei Möglichkeiten, dieses Tool zu verwenden:
-
Als prozedurale Makrofunktion, die die Bindings direkt innerhalb der Implementierungsdatei generiert. Diese Methode ist Standard, hat aber den Nachteil, dass der generierte Binding-Code nicht inspiziert werden kann.
-
Als Kommandozeilentool, das eine Binding-Datei auf der Festplatte erstellt. Dieser Ansatz wird im Code des VS Code Extension Sample Repository für das untenstehende Ressourcenbeispiel veranschaulicht.
Die entsprechende Rust-Datei, die das wit-bindgen-Tool als prozedurale Makrofunktion verwendet, sieht wie folgt aus:
// 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",
});
Das Kompilieren der Rust-Datei nach WebAssembly mit dem Befehl cargo build --target wasm32-unknown-unknown führt jedoch zu Kompilierungsfehlern, da die exportierte calc-Funktion nicht implementiert ist. Nachfolgend eine einfache Implementierung der calc-Funktion.
// 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);
Die export!(Calculator);-Anweisung am Ende der Datei exportiert den Calculator aus dem WebAssembly-Code, damit die Erweiterung die API aufrufen kann.
Das Tool wit2ts wird verwendet, um die notwendigen TypeScript-Bindings für die Interaktion mit dem WebAssembly-Code innerhalb einer VS Code-Erweiterung zu generieren. Dieses Tool wurde vom VS Code-Team entwickelt, um den spezifischen Anforderungen der VS Code-Erweiterungsarchitektur gerecht zu werden, hauptsächlich weil:
- Die VS Code API ist nur innerhalb des Extension Host Workers zugänglich. Jeder zusätzliche Worker, der vom Extension Host Worker gestartet wird, hat keinen Zugriff auf die VS Code API. Dies steht im Gegensatz zu Umgebungen wie NodeJS oder dem Browser, in denen jeder Worker typischerweise Zugriff auf fast alle Laufzeit-APIs hat.
- Mehrere Erweiterungen teilen sich denselben Extension Host Worker. Erweiterungen sollten es vermeiden, zeitaufwändige synchrone Berechnungen auf diesem Worker durchzuführen.
Diese Architekturanforderungen bestanden bereits, als wir die WASI Preview 1 für VS Code implementierten. Unsere anfängliche Implementierung wurde jedoch manuell erstellt. In Erwartung einer breiteren Akzeptanz des Component Models haben wir ein Tool entwickelt, um die Integration von Komponenten mit ihren VS Code-spezifischen Host-Implementierungen zu erleichtern.
Der Befehl wit2ts --outDir ./src ./wit erzeugt eine Datei calculator.ts im Verzeichnis src, die die TypeScript-Bindings für den WebAssembly-Code enthält. Eine einfache Erweiterung, die diese Bindings verwendet, würde wie folgt aussehen:
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)}`);
})
);
}
Wenn Sie den obigen Code in VS Code für das Web kompilieren und ausführen, erzeugt er die folgende Ausgabe im Kanal Calculator:
Sie finden den vollständigen Quellcode für dieses Beispiel im VS Code Extension Sample Repository.
Innerhalb von @vscode/wasm-component-model
Die Inspektion des vom wit2ts-Tool generierten Quellcodes zeigt seine Abhängigkeit vom npm-Modul @vscode/wasm-component-model. Dieses Modul dient als VS Code-Implementierung der kanonischen ABI des Component Models und ist von entsprechenden Python-Codes inspiriert. Obwohl es nicht notwendig ist, die Interna des Component Models zu verstehen, um diesen Blogbeitrag zu verstehen, werden wir einen Einblick in seine Funktionsweise geben, insbesondere in Bezug auf die Datenübertragung zwischen JavaScript/TypeScript und WebAssembly-Code.
Im Gegensatz zu anderen Tools wie wit-bindgen oder jco, die Bindings für WIT-Dateien generieren, erstellt wit2ts ein Metamodell, das dann zur Laufzeit Bindings für verschiedene Anwendungsfälle generieren kann. Diese Flexibilität ermöglicht es uns, die Architekturanforderungen für die Erweiterungsentwicklung innerhalb von VS Code zu erfüllen. Mit diesem Ansatz können wir die Bindings "promisify"en und die Ausführung von WebAssembly-Code in Workern ermöglichen. Wir nutzen diesen Mechanismus, um die WASI 0.2 Preview für VS Code zu implementieren.
Sie haben vielleicht bemerkt, dass Funktionen beim Generieren der Bindings unter Namen wie calculator._.imports.create (beachten Sie den Unterstrich) referenziert werden. Um Namenskollisionen mit Symbolen in der WIT-Datei zu vermeiden (z. B. könnte es eine Typdefinition namens imports geben), werden API-Funktionen in einem _-Namespace platziert. Das Metamodell selbst befindet sich in einem $-Namespace. Somit repräsentiert calculator.$.exports.calc die Metadaten für die exportierte calc-Funktion.
Im obigen Beispiel besteht der add-Operationsparameter, der an die calc-Funktion übergeben wird, aus drei Feldern: dem Operationscode, dem linken Wert und dem rechten Wert. Gemäß der kanonischen ABI des Component Models werden Argumente by value übergeben. Sie beschreibt auch, wie die Daten serialisiert, an WebAssembly-Funktionen übergeben und auf der anderen Seite deserialisiert werden. Dieser Prozess führt zu zwei Operations-Objekten: einem im JavaScript-Heap und einem anderen im linearen WebAssembly-Speicher. Das folgende Diagramm veranschaulicht dies.

Die folgende Tabelle listet die verfügbaren WIT-Typen, ihre Zuordnung zu JavaScript-Objekten in der VS Code Component Model-Implementierung und die entsprechenden verwendeten TypeScript-Typen auf.
| 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 | ? und (T | undefined) |
| result<ok, err> | Exception oder object literal | Exception oder result type |
Es ist wichtig zu beachten, dass das Component Model keine Low-Level-Pointer (C-Stil) unterstützt. Daher können Sie keine Objektgraphen oder rekursiven Datenstrukturen übergeben. In dieser Hinsicht teilt es die gleichen Einschränkungen wie JSON. Um Datenkopien zu minimieren, führt das Component Model das Konzept von Ressourcen ein, das wir in einem zukünftigen Abschnitt dieses Blogbeitrags näher erläutern werden.
Das jco-Projekt unterstützt auch die Generierung von JavaScript/TypeScript-Bindings für WebAssembly-Komponenten mithilfe des type-Befehls. Wie bereits erwähnt, haben wir unsere eigenen Tools entwickelt, um den spezifischen Anforderungen von VS Code gerecht zu werden. Wir halten jedoch alle zwei Wochen Besprechungen mit dem jco-Team ab, um die bestmögliche Abstimmung zwischen den Tools sicherzustellen. Eine grundlegende Anforderung ist, dass beide Tools die gleichen JavaScript- und TypeScript-Darstellungen für WIT-Datentypen verwenden. Wir untersuchen auch Möglichkeiten, Code zwischen den beiden Tools zu teilen.
Aufruf von TypeScript aus WebAssembly-Code
WIT-Dateien beschreiben die Interaktion zwischen dem Host (einer VS Code-Erweiterung) und dem WebAssembly-Code und ermöglichen eine bidirektionale Kommunikation. In unserem Beispiel ermöglicht diese Funktion dem WebAssembly-Code, Traces seiner Aktivitäten zu protokollieren. Um dies zu ermöglichen, ändern wir die WIT-Datei wie folgt:
world calculator {
/// ....
/// A log function implemented on the host side.
import log: func(msg: string);
/// ...
}
Auf der Rust-Seite können wir nun die Log-Funktion aufrufen.
fn calc(op: Operation) -> u32 {
log(&format!("Starting calculation: {:?}", op));
let result = match op {
// ...
};
log(&format!("Finished calculation: {:?}", op));
result
}
Auf der TypeScript-Seite besteht die einzige erforderliche Aktion für einen Erweiterungsentwickler darin, eine Implementierung der Log-Funktion bereitzustellen. Das VS Code Component Model ermöglicht dann die Generierung der notwendigen Bindings, die als Imports an die WebAssembly-Instanz übergeben werden.
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);
// ...
}
Im Vergleich zum ersten Beispiel enthält der Aufruf von WebAssembly.instantiate nun das Ergebnis von calculator._.imports.create(service, wasmContext) als zweites Argument. Dieser imports.create-Aufruf generiert die Low-Level-WASM-Bindings aus der Service-Implementierung. Im ursprünglichen Beispiel haben wir ein leeres Objekt-Literal übergeben, da keine Imports erforderlich waren. Dieses Mal führen wir die Erweiterung in der VS Code Desktop-Umgebung unter dem Debugger aus. Dank der hervorragenden Arbeit von Connor Peet ist es nun möglich, Breakpoints im Rust-Code zu setzen und ihn mit dem VS Code-Debugger zu durchlaufen.
Verwendung von Component Model-Ressourcen
Das WebAssembly Component Model führt das Konzept von Ressourcen ein, das einen standardisierten Mechanismus zur Kapselung und Verwaltung von Zustand bietet. Dieser Zustand wird auf einer Seite der Aufrufgrenze verwaltet (z. B. in TypeScript-Code) und auf der anderen Seite zugegriffen und manipuliert (z. B. in WebAssembly-Code). Ressourcen werden in den WASI Preview 0.2-APIs intensiv genutzt, wobei Dateideskriptoren ein typisches Beispiel sind. In diesem Setup wird der Zustand vom Extension Host verwaltet und vom WebAssembly-Code zugegriffen und manipuliert.
Ressourcen können auch in umgekehrter Richtung fungieren, wobei ihr Zustand vom WebAssembly-Code verwaltet und vom Erweiterungscode zugegriffen und manipuliert wird. Dieser Ansatz ist besonders vorteilhaft für VS Code, um zustandsbehaftete Dienste in WebAssembly zu implementieren, auf die dann von der TypeScript-Seite zugegriffen wird. Im folgenden Beispiel definieren wir eine Ressource, die einen Rechner implementiert, der die Reverse Polish Notation unterstützt, ähnlich wie sie in Hewlett-Packard-Taschenrechnern verwendet wird.
// 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;
}
Nachfolgend eine einfache Implementierung der Rechnerressource in 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()
}
}
Im TypeScript-Code binden wir die Exporte auf die gleiche Weise wie zuvor. Der einzige Unterschied besteht darin, dass der Binding-Prozess uns nun eine Proxy-Klasse zur Verfügung stellt, die zum Instanziieren und Verwalten einer calculator-Ressource innerhalb des WebAssembly-Codes verwendet wird.
// 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}`);
})
);
Wenn Sie den entsprechenden Befehl ausführen, wird Result: 60 im Ausgabekanal ausgegeben. Wie bereits erwähnt, befindet sich der Zustand von Ressourcen auf einer Seite der Aufrufgrenze und wird von der anderen Seite über Handles angesprochen. Es erfolgt keine Datenkopie, mit Ausnahme der Argumente, die an Methoden übergeben werden, die mit den Ressourcen interagieren.

Der vollständige Quellcode für dieses Beispiel ist im VS Code Extension Sample Repository verfügbar.
VS Code APIs direkt aus Rust aufrufen
Component Model-Ressourcen können zur Kapselung und Verwaltung von Zustand über WebAssembly-Komponenten und den Host hinweg dienen. Diese Fähigkeit ermöglicht es uns, Ressourcen zu verwenden, um die VS Code API kanonisch in den WebAssembly-Code zu exponieren. Der Vorteil dieses Ansatzes liegt in der Tatsache, dass die gesamte Erweiterung in einer Sprache geschrieben werden kann, die nach WebAssembly kompiliert wird. Wir haben begonnen, diesen Ansatz zu untersuchen, und nachfolgend finden Sie den Quellcode einer in Rust geschriebenen Erweiterung.
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() {
}
Beachten Sie, dass dieser Code einer in TypeScript geschriebenen Erweiterung ähnelt.
Obwohl diese Untersuchung vielversprechend erscheint, haben wir beschlossen, sie vorerst nicht weiterzuverfolgen. Der Hauptgrund ist das Fehlen von asynchroner Unterstützung in WASM. Viele VS Code APIs sind asynchron, was ihre direkte Weiterleitung in den WebAssembly-Code erschwert. Wir könnten den WebAssembly-Code in einem separaten Worker ausführen und denselben Synchronisationsmechanismus verwenden, der in der WASI Preview 1-Unterstützung zwischen dem WebAssembly-Worker und dem Extension Host Worker verwendet wird. Dieser Ansatz könnte jedoch zu unerwartetem Verhalten bei synchronen API-Aufrufen führen, da diese Aufrufe tatsächlich asynchron ausgeführt würden. Infolgedessen könnte sich der beobachtbare Zustand zwischen zwei synchronen Aufrufen ändern (z. B. setX(5); getX(); gibt möglicherweise nicht 5 zurück).
Darüber hinaus laufen derzeit Bemühungen, die vollständige asynchrone Unterstützung für WASI im Rahmen der 0.3 Preview einzuführen. Luke Wagner gab auf der WASM I/O 2024 ein Update zum aktuellen Stand der asynchronen Unterstützung. Wir haben beschlossen, auf diese Unterstützung zu warten, da sie eine vollständigere und sauberere Implementierung ermöglichen wird.
Wenn Sie an den entsprechenden WIT-Dateien, dem Rust-Code und dem TypeScript-Code interessiert sind, finden Sie diese im Ordner rust-api des vscode-wasm-Repositorys.
Was kommt als Nächstes
Wir bereiten derzeit einen Folgebeitrag vor, der weitere Bereiche abdeckt, in denen WebAssembly-Code für die Entwicklung von Erweiterungen verwendet werden kann. Die Hauptthemen werden sein:
- Schreiben von Language Servern in WebAssembly.
- Verwenden des generierten Metamodells, um zeitaufwändigen WebAssembly-Code transparent in einen separaten Worker auszulagern.
Mit einer VS Code-idiomatischen Implementierung des Component Models setzen wir unsere Bemühungen fort, die WASI 0.2 Preview für VS Code zu implementieren.
Danke,
Dirk und das VS Code-Team
Viel Spaß beim Programmieren!