C# × WASM化を使用してVS Code の拡張機能を作りたい(VS Code ローカル)
はじめに
突然ですが、皆さんはVS Codeを使用してますでしょうか。私はめちゃくちゃ使ってます。このzenn記事もVS Codeで作成してます。VS Codeを使わないならPCは捨てても良いってぐらい使ってます。
さらに最近はAIブームやgithub copilotチャットの出現もあり、使う頻度が爆増しています。
そんなさなか、VS Codeって、Electronベースだったはずだから、WebAssembly(WASM)とも相性が良いはず?、、などと思い、VS Code拡張機能の開発、本記事作成に至ります。
(あわよくば、AI関連に掠るようなツールを作れれば面白そう、、という邪な心もあります。。)
やりたいこと
端的に申しますと、
VS Codeの拡張機能をC#で記述したWASMを使用して作成したい、です。
WASMの王道はRustやGOで作成すると思いますし、パフォーマンス的にも良いと考えます。が、やっぱり慣れ親しんだ.NET ファミリーで開発したい場面があるじゃないですか。ないですかね。あるとしましょう。
ということで、
以下はVS CodeとWASMを含む拡張機能との関係を図示したイメージです。
むちゃくちゃ邪な動機も含みますが、次項以降でさっそく作っていきたいと思います。
Visual Studio Code (VS Code)とは
VS Code(Visual Studio Code)とは、軽量で高機能な無料のコードエディタで、幅広いプログラミング言語と拡張機能に対応した開発者向けツールです。
いわずもがな、VS Codeは、Microsoftが開発・提供する無料のコードエディタです。
WebAssembly(WASM)とは
WebAssembly(略称Wasm)は、スタックベースの仮想マシンのバイナリ命令形式です。Wasmは、プログラミング言語のポータブルコンパイルターゲットとして設計されており、クライアントおよびサーバーアプリケーション用のWeb上での展開を可能にします。
Lethediana Tech様よりイメージ図を引用
要するに、「Webブラウザなどで高速に動く、コンパクトなバイナリ形式の実行コード」という理解です。
本記事は、Webブラウザはあまり関係なく、、VS Codeローカルで動作するWASMを含む拡張機能を作成しますが、ゆくゆくはVS code for webで動作するWASMを含む拡張機能について整理したいと考えてます。
環境
開発環境
以下は開発環境にインストールされているものとします。
- VS Code (V1.103.1)
- .NET 9 SDK
- node.js (v22.14.0)
VS Code 拡張機能
以下は、VS Code拡張機能を開発するために必要なVS Codeの拡張機能です。
- ESLint
- TypeScript + Webpack Problem Matchers
- Extension Test Runner
構築
いよいよ本項よりVS Code拡張機能の作成を行っていきます。
1. VS Code 拡張機能ひな形を作成
VS Code拡張機能のひな形作成出力は何番煎じかわかりませんが、まずはプロジェクトひな形を作成していきます。
Yeoman Generator-Codeをインストール
Yeoman Generator-Codeをインストールします。Yeoman Generatorは、Webアプリやプロジェクトの雛形を自動生成するためのNode.jsベースのスキャフォールディングツールです。
npm install -g generator-code
本記事作成時点はv5.1.0でした。
また、ソース一式を格納するプロジェクトフォルダを作成しておきます。
Yeoman Generator-Codeでひな形作成
インストールを行ったYeomanでVS Code拡張のひな形を出力します。
yo code でgeneratorを起動します。
コマンド実行は作成したフォルダC:\src\vscode-extension-test3で行います。
npx yo code
New Extension (TypeScript)を選択します。
C:\src\vscode-extension-test3>npx yo code
_-----_ ╭──────────────────────────╮
| | │ Welcome to the Visual │
|--(o)--| │ Studio Code Extension │
`---------´ │ generator! │
( _´U`_ ) ╰──────────────────────────╯
/___A___\ /
| ~ |
__'.___.'__
´ ` |° ´ Y `
? What type of extension do you want to create? (Use arrow keys)
> New Extension (TypeScript)
New Extension (JavaScript)
New Color Theme
New Language Support
New Code Snippets
New Keymap
New Extension Pack
New Language Pack (Localization)
New Web Extension (TypeScript)
New Notebook Renderer (TypeScript)
generateする過程でいくつかの質問がありますが、以下のように構成を回答しています。
? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? vscode-my-extension3
? What's the identifier of your extension? vscode-my-extension3
? What's the description of your extension?
? Initialize a git repository? No
? Which bundler to use? webpack
? Which package manager to use? npm
Writing in C:\src\vscode-extension-test3\vscode-my-extension3...
? Which bundler to use? webpack
? Which package manager to use? npm
は環境に合わせて選択ください。好みもあるかと思います。
無事、VS Code拡張機能のひな形が作成されました。
以下は拡張機能のひな形プロジェクトの概要です。
vscode-my-extension3/
├── .vscode/
│ └── launch.json, tasks.json // デバッグやビルドなど、VSCodeエディタ固有の設定ファイルを格納します。
├── src/
│ └── extension.ts // 拡張機能のメインロジック(TypeScriptで記述)を含むディレクトリです。
├── dist/
│ └── extension.js // TypeScriptで書いたコードをビルドした後の成果物(JavaScript)が入ります。
├── package.json
│ // 拡張機能の名前、バージョン、依存パッケージ、コマンド定義などを記載する最重要ファイルです。
│ // VSCode拡張機能として公開する際の情報もここにまとめます。
├── vsc-extension-quickstart.md
│ // プロジェクトの初期ガイダンス。開発の流れや基本的な使い方が書かれています。
├── webpack.config.js
│ // TypeScriptコードをJavaScriptにまとめるためのwebpack設定ファイルです。
├── tsconfig.json
│ // TypeScriptのコンパイル設定(どのバージョンで書くか、どこに出力するか等)を記載します。
├── .eslintrc.js
│ // コード品質を保つためのESLintの設定ファイルです。書き方のルールを定義します。
└── その他テスト・設定ファイル
└── test/ // 拡張機能の動作確認用テストコードを格納するディレクトリです。
以降の操作はプロジェクトフォルダ(本記事の内容ですと、vscode-my-extension3フォルダ)の直下で行います。
Generate中に発生したエラー(私の環境のみ?)
拡張機能のコンパイル
ここまでくると、サンプルのHelloworldが実行できるはずですので、一度実行確認を行います。
以下コマンドで拡張機能ひな形のコンパイルを行います。
npm run compile
コンパイル完了後、F5を押下することでデバッグが始まります。
別プロセスでVS Codeが立ち上がりますので、コマンドパレットからHelloWorldを入力します。
無事、Hello Worldのトーストメッセージが表示されました。
特になにも編集してないにも関わらず、
はじめて実行したときは、簡単に動きすぎて、感動ものでした。全能感というかなんというか、なんでもできそうな気がしましたね。ええ。
補足
補足ですが、package.jsonのcontributes:commandsのtitleとexport function activateメソッド内のvscode.commands.registerCommandの第一引数が対応しているようです。
{
//省略
"contributes": {
"commands": [
{
"command": "vscode-my-extension3.helloWorld",
"title": "Hello World"
}
]
},
//省略
}
//省略
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand('vscode-my-extension3.helloWorld', () => {
vscode.window.showInformationMessage('Hello World from vscode-my-extension3!');
});
context.subscriptions.push(disposable);
}
//省略
2. .NET SDKでWASM化するC#プロジェクトを作成
C# プロジェクトの作成
以下のdotnetコマンドでWASM用のC#プロジェクトを準備します。
dotnet new console `
-n MyWasmApp `
--framework net9.0 `
--use-program-main true
wasmへのビルドは最低限exeがないとダメなようなので、consoleアプリとしています。
dotnet new コマンド実行後、無事C#のconsoleプロジェクトが作成されました。
呼び出されるC#側処理を作成
Typescriptから呼び出される処理を記述します。実際の実装では本項目部分を作りこむことになるかと思います。
ポイントは以下です。
- Typescript側から呼び出すため、JSExport属性を付与します。
- Typescript側から呼び出すため、静的クラス、静的メソッドとします。
- JSExport属性使用の関係上、partialクラスとします。
- JSExport属性はunsafeブロックとなるため、ビルド時、またはcsprojでAllowUnsafeBlocks=trueを指定します。(後述)
今回は、Class1.csを追加し、サンプル的なメソッドとして、以下を実装しています。
- GetGreeting()
: 名前に基づいて挨拶メッセージを生成します。
using System.Threading.Tasks;
using System.Runtime.InteropServices.JavaScript;
namespace MyWasmApp;
// JSExportを使用する関係上、partialクラスとします。
public static partial class Class1
{
// TypeScript/JavaScriptから呼び出せる静的メソッドの例(JSExport属性を使用)
// JSからの呼び出し方法はランタイムやバンドル方法によって異なります。ここでは属性をJSExportに置き換えた例を示します。
/// <summary>
/// 名前に基づいて挨拶メッセージを生成します。
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[JSExport]
public static string GetGreeting(string name)
{
if (string.IsNullOrWhiteSpace(name))
return "Hello from WASM!";
return $"Hello, {name}, from WASM!";
}
}
デバッグ等はC# Dev kit拡張機能でも構いませんし、別途Visual Studio を開いてデバッグをしても良いかと思います。
VS Code 用 C# 開発キット拡張機能の使用について
3. .NET SDKでC#プロジェクトをWASMにビルド
wasm-toolsをインストール
C#プロジェクトをWAMSにビルドするためのwasm-toolsをインストールします。すでにインストール済みの場合はスキップいただいて構いません。Visual Studio インストーラーの変更オプションでもインストール可能です。
dotnet workload install wasm-tools
WASMにビルド
以下のdotnetコマンドでC#プロジェクトをWAMSで発行します。
発行先のフォルダは、本拡張機能直下のmediaフォルダを想定としています。
ビルドも同時に行うので、この時点でビルドエラーがある場合はターミナルに表示されるかと思います。
dotnet publish .\MyWasmApp\MyWasmApp.csproj `
-c Release `
-r browser-wasm `
--self-contained true `
/p:RunAOTCompilation=true `
/p:InvariantGlobalization=true `
/p:StripSymbols=true `
/p:AllowUnsafeBlocks=true `
-o .\media
publishのパラメータとして以下を指定しています。csprojで指定しても構いません。
- -c Release
: Build Configuration を Release モードに指定(最適化有効) - -r browser-wasm
: 実行ターゲットを WebAssembly(ブラウザ)ランタイム に指定 - --self-contained true
: .NET ランタイムを同梱した自己完結型ビルドにする(ユーザー環境に .NET がなくても動作) - RunAOTCompilation=true
: Ahead-of-Time コンパイルを有効化(事前にネイティブコード化しパフォーマンス改善) - InvariantGlobalization=true
: グローバル化サポートを簡略化(カルチャ依存機能を無効化)してサイズ縮小 - StripSymbols=true
: デバッグシンボルを除去してファイルサイズ削減 - AllowUnsafeBlocks=true
: unsafe コードブロックのコンパイルを許可
無事、発行が完了しました。
4. TypescriptでWASMの呼び出し処理作成
「2..NET SDKでWASM化するC#プロジェクトを作成」で作成したC#側の処理をTypscriptから呼び出しの処理を作成します。
package.jsonの編集
まずはpackage.jsonに本機能の処理をトリガーするイベントを追加するとします。
Activateイベントは他にもonNotebook(特定の文書を開いたとき)などたくさんあるのですが、今回はHelloWorldサンプルを踏襲して、コマンドパレットで入力キックできるコマンドを追加することとします。
{
//省略
"contributes": {
"commands": [
{
"command": "vscode-my-extension3.helloWorld",
"title": "Hello World"
},
+ {
+ "command": "vscode-my-extension3.callWasm.greeting",
+ "title": "WASM: Class1.GetGreeting"
+ }
]
},
//省略
}
extension.tsの編集
ここで、TypeScript 側のコードは、単に WASM を置くだけでは実行できないので、
「WASM ランタイムを起動 → DLL を見つけてロード → メソッドを JS に公開」
という処理が必要です。
コード上の流れとしては以下です。
-
media フォルダの実ファイルパスを取得する
- WASM を配置しているパスを解決する。
-
dotnet.js
を import する- WASM ランタイムを起動するための「入り口」を読み込む。
-
ビルダー API を取得する
- ランタイムを設定・起動するためのオブジェクトを取り出す。
- この API がなければ WASM を動かすことができない。
-
リソースローダーを定義する
- ランタイムが要求する DLL や WASM をどのパスから取るかを教える。
- これがないとファイルが見つからず、アプリが起動できない。
-
config(構成情報)を用意する
- メインのアセンブリ名、読み込む DLL 群、ICU 設定などを指定。
- CLR に「何をどう実行するか」を知らせる。
-
dotnet.create()
でランタイムを起動する- この瞬間に WASM がロードされ、.NET CLR が動き出す。
-
getAssemblyExports
で DLL 内の関数をラップする- JS から直接呼べる関数として公開。
-
呼び出し
- 起動したランタイムgetAssemblyExports('MyWasmApp')から呼び出し。
上記を踏まえ、extension.tsは以下のように編集します。
import * as vscode from 'vscode';
import * as path from 'path';
import { pathToFileURL } from 'url';
import * as fsp from 'fs/promises';
export function activate(context: vscode.ExtensionContext) {
console.log('Congratulations, your extension "vscode-my-extension3" is now active!');
const disposable = vscode.commands.registerCommand('vscode-my-extension3.helloWorld', () => {
vscode.window.showInformationMessage('Hello World from vscode-my-extension3!');
});
context.subscriptions.push(disposable);
// 単純な直列フローで MyWasmApp.Class1.GetGreeting を呼び出すコマンド
context.subscriptions.push(
vscode.commands.registerCommand('vscode-my-extension3.callWasm.greeting', async () => {
try {
// 1) media フォルダの実ファイルパスを解決
const mediaFsPath = vscode.Uri.joinPath(context.extensionUri, 'media').fsPath;
// 2) dotnet.js を動的 import(webpack による変換を避けるため eval を使用)
const dotnetJsUrl = pathToFileURL(path.join(mediaFsPath, 'dotnet.js')).href;
const mod: any = await (0, eval)(`import(${JSON.stringify(dotnetJsUrl)})`);
// 3) dotnet ビルダー API を取得
const dotnet = mod?.dotnet ?? mod?.default?.dotnet ?? mod?.default ?? mod;
if (!dotnet) {
throw new Error('dotnet API not found in dotnet.js');
}
// 4) リソースローダー: ランタイムが要求するリソース名 → media 内の file:// URL に解決
const resourceLoader = (_type: string, name: string) => {
const abs = path.join(mediaFsPath, name);
return pathToFileURL(abs).href;
};
// 5) resources 定義を media の内容から動的生成
const entries = await fsp.readdir(mediaFsPath);
const assemblies = Object.fromEntries(entries.filter(f => f.endsWith('.dll')).map(f => [f, '']));
const hasIcu = entries.includes('icudt.dat');
const config = {
// アプリのメインアセンブリ名(プロジェクト名)
mainAssemblyName: 'MyWasmApp',
debugLevel: 0,
cacheBootResources: false,
// ICU がある場合は custom、無い場合は invariant
globalizationMode: hasIcu ? 'custom' : 'invariant',
resources: {
assembly: assemblies,
wasmNative: { 'dotnet.native.wasm': '' },
jsModuleRuntime: { 'dotnet.runtime.js': '' },
jsModuleNative: { 'dotnet.native.js': '' },
...(hasIcu ? { icu: { 'icudt.dat': '' } } : {})
}
} as const;
// 6) ランタイムを起動
const { getAssemblyExports } = await dotnet
.withConfig(config)
.withResourceLoader(resourceLoader)
.withDiagnosticTracing(false)
.create();
// 7) MyWasmApp のエクスポートを取得し、Class1.GetGreeting を呼ぶ
const exp = await getAssemblyExports('MyWasmApp');
const api = exp?.MyWasmApp?.Class1;
if (!api?.GetGreeting) {
throw new Error('MyWasmApp.Class1.GetGreeting が見つかりません');
}
// 8) 呼び出し
const result: unknown = await api.GetGreeting('VS Code');
vscode.window.showInformationMessage(`GetGreeting => ${result}`);
} catch (e) {
// 失敗時はエラー内容を通知
vscode.window.showErrorMessage(`GetGreeting failed: ${e}`);
}
})
);
}
export function deactivate() {}
図示すると以下の流れです。
extension.tsを編集後は、
npm run compile
は忘れずに実施します。
5. VS Codeに拡張機能をインストール
vsixファイル作成
拡張機能ファイルのVSIXを作成します。
プロジェクト直下にて以下のコマンドを実行します。
npx vsce package
実行後、「vscode-my-extension3-0.0.1.vsix 」が作成されました。
vsixファイルのインストール
VSIXからインストールを選択します。
上記「vsixファイル作成」で作成したvsixファイルを指定します。
指定後、無事インストールされました。
動作確認
インストールした拡張機能を実行してみます。
いざいざ実行です。
無事、GetGreeting => Heloo,VS Code. from WASM
がトーストメッセージで表示されました。泣いた。
まとめ
とうわけで、VS Code拡張機能からWASMを利用してC#で作成した処理を呼び出しました。整理しますとTypescript(js)で.NETのWASMを動かすには下記の点に注意が必要です。
- .NET側
- JS側から.NET コードを呼ぶ際はJSExport属性を付ける。
- JSExportでは呼び出しは静的メソッドである必要がある。
- Typescript側(JS側)
- JS側からWASMを動かす際は、dotnet.jsでランタイムの起動が必要。
- ランタイム実行は以下4点も必要。
- dotnet.native.wasm
- dotnet.runtime.js
- dotnet.native.js
- 他依存関係dllなど
本記事を書いてる途中で、
「VS Codeローカルなら、別にWASMである必要ないよね」とめちゃくちゃ思いましたが、理解が深まったので良しとしましょう。
本当は、VS Code for webで使用するweb拡張機能についての内容にできればよかったのですが、、「そもそもyo code Generator でNew Web Extension (TypeScript)でプロジェクトを作成しないとまずくない!?」と気づいてしまいました。こちらも理解が深まったので良しとしましょう。してください。
VS Code for Web 対応のWASM を含むweb拡張機能については、別記事で作成するとします。
本記事の内容が何かしらのお役に立てば幸いです。
参考
参考リンク
- WebAssembly
- MDN_WebDocs WebAssembly の概要
- Qiita WebAssemblyとは
- VS code Extension API
- VS Code Extension Manifest
- Zenn VSCode拡張機能のつくり方
- Developers IO かんたん!VS Code拡張機能開発
- Microsoft Learn ASP.NET Core Blazor を使用した JavaScript [JSImport]/[JSExport] 相互運用
Discussion