Extending VSCode with WebAssembly

Two years ago I filed Microsoft/vscode#65559 asking for WebAssembly support in VSCode extensions. At the time, WASM was supported by Node.JS but the WebAssembly symbol wasn't available in the extension's evaluation scope. That issue didn't get much activity from upstream but the other day I tried it again, and … it worked!

Below is a small "hello world" LSP-based extension that loads a WASM module in onInitialize(). It uses the vscode-languageserver library; readers new to VSCode extensions can follow along using Microsoft's Your First Extension and Language Server Extension Guide tutorials.

server.wasm

First up, we'll need the WASM file itself. I wrote two flavors (C and Rust) with equivalent API, returning a single static string. More complex APIs can use Emscripten or wasm-bindgen or whatever to deal with the FFI.

Option 1: C

char *greeting() {
	return "Hello world (from C)!";
}

clang --target=wasm32 \
#   --no-standard-libraries \
#   -Wl,--export-all \
#   -Wl,--no-entry \
#   -o out/server.wasm \
#   src/server.c

Option 2: Rust

#![no_std]

#[no_mangle]
pub extern "C" fn greeting() -> *const u8 {
	const HELLO: &'static str = "Hello world (from Rust)!\0";
	HELLO.as_ptr()
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

rustc -O \
#   --target wasm32-unknown-unknown \
#   --crate-type=cdylib \
#   -o out/server.wasm \
#   src/server.rs

Either way you'll get a WASM module looking something like this:

wasm2wat out/server.wasm
# (module
#   (type (;0;) (func (result i32)))
#   (func $greeting (type 0) (result i32)
#     i32.const 1048576)
#   (table (;0;) 1 1 funcref)
#   (memory (;0;) 17)
#   (global (;0;) (mut i32) (i32.const 1048576))
#   (global (;1;) i32 (i32.const 1048601))
#   (global (;2;) i32 (i32.const 1048601))
#   (export "memory" (memory 0))
#   (export "greeting" (func $greeting))
#   (export "__data_end" (global 1))
#   (export "__heap_base" (global 2))
# 	(data (;0;) (i32.const 1048576) "Hello world (from Rust)!\00"))

server.ts

This file implements the server side of an LSP-based VSCode extension. For the sake of brevity I won't have it implement any handlers, so it just loads the WASM module and sends a message once successfully initialized.

import * as fs from "fs";
import * as path from "path";

import {
	createConnection,
	ProposedFeatures,
	InitializeParams,
	TextDocumentSyncKind,
	InitializeResult
} from 'vscode-languageserver/node';

declare const WebAssembly: any;

The only unusual part is the declare, which is necessary because TypeScript doesn't have type definitions for standalone WASM yet (DefinitelyTyped/DefinitelyTyped#48648). Since the WebAssembly API is small we can stub out the type checks.

Next up is a helper class to load the module from disk, compile it, and call its exported functions.

class ServerImpl {
	instance: any;

	constructor(instance: any) {
		this.instance = instance;
	}

	public static instantiate(): Promise<ServerImpl> {
		const wasmPath = path.resolve(__dirname, "server.wasm");
		return new Promise((resolve, reject) => {
			fs.readFile(wasmPath, (err, data) => {
				if (err) {
					reject(err);
					return;
				}
				const buf = new Uint8Array(data);
				resolve(WebAssembly.instantiate(buf, {})
					.then((result: any) => (new ServerImpl(result.instance)))
				);
			});
		});
	}

	public greeting(): String {
		const exports = this.instance.exports;
		const result_off = exports.greeting();
		const result_ptr = new Uint8Array(exports.memory.buffer, result_off);
		let result = "";
		for (let ii = 0; result_ptr[ii]; ii++){
			result += String.fromCharCode(result_ptr[ii]);
		}
		return result;
	}
}

Lastly the server startup and initialization logic, which calls into the helper to fetch the greeting string.

let impl: ServerImpl;
let connection = createConnection(ProposedFeatures.all);

connection.onInitialize((params: InitializeParams) => {
	const result: InitializeResult = {
		capabilities: {
			textDocumentSync: TextDocumentSyncKind.Incremental,
		}
	};
	return ServerImpl.instantiate()
		.then((loadedImpl: ServerImpl) => {
			impl = loadedImpl;
			return result;
		});
});

connection.onInitialized(() => {
	connection.window.showInformationMessage(`greeting: ${impl.greeting()}`);
});

connection.listen();

When the "Hello World" command is invoked via the command menu, the extension will be initialized and the greeting will pop up.