webassemblyで自作言語のplaygroundを作ってみた


TL;DR

  • 「The Go Playground」みたいなweb上言語実行環境の自作言語版を作成: Pangaea Playground
  • syscall/jsを使い、Go製インタープリターをwasmへコンパイルして実行
  • GitHub Actionsでビルド、GitHub Pagesへデプロイ

はじめに

昨年(2020年)から、プログラミング言語「Pangaea」を自作しています。

言語仕様については別記事で紹介しておりますのでよろしければご覧ください。

ホスト言語はGo言語で、インタープリターのバイナリも公開しています。
バイナリをダウンロードするだけで使えるのですが、「ちょっと試してみるか」というときにはやはりweb上の方がとっつきやすいですよね...

というわけで、布教しやすいように Web上のPangaea実行環境を作成しました。
The Go Playground」にあやかって、「Pangaea Playground」という名前にしています。

構成

インタープリターをwasmにコンパイルし、jsから呼び出しています。

ファイル構成
- index.html
- style.css
- pangaea.js (wasmのfetch、セットアップ)
- main.wasm (インタープリターのバイナリ)
- wasm_exec.js (golangをコンパイルしたwasmの実行に必要)

wasmのビルド

特別なツールは不要で、go buildにフラグを指定するだけでwasmが生成されます。

GOOS=js GOARCH=wasm go build -o main.wasm

参考:

普通のgo buildと違い、mainパッケージ以外をwasmにビルドしようとすると失敗します!(後述「wasmの謎エラー」)

インタープリター関数をjsから呼び出せるようにする

インタープリターにはソースコード、標準入力の引数を渡したいので、syscall/js パッケージを使いjsの関数として登録します。

js.Global().Set()でオブジェクトを登録することで、pangaeaインタープリターの関数をjs上pangaea.execute()で呼び出せるようになります。

web/wasm/register.go
js.Global().Set("pangaea", js.ValueOf(
    map[string]interface{}{
        "execute": js.FuncOf(ex.Execute),
    },
))

あとは、func (this js.Value, args []js.Value) interface{} のシグネチャに合うようにインタープリター関数をラップしてあげれば実装終了です。

https://github.com/Syuparn/Pangaea/blob/master/web/wasm/executor.go#L30

web/wasm/executor.go
// シグネチャは (src, stdin) => ({res: res, stdout; stdout, errmsg: errmsg})の形式
func (e *Executor) Execute(this js.Value, args []js.Value) interface{} {
    src := e.setupSrc(args)
    stdin := e.setupStdin(args)
    stdout := &bytes.Buffer{}
    // ソースコード実行
    res, errmsg := e.execute(src, stdin, stdout)

    if errmsg != "" {
        return map[string]interface{}{
            "res":    "",
            "stdout": stdout.String(),
            "errmsg": errmsg,
        }
    }

    return map[string]interface{}{
        "res":    res.Repr(),
        "stdout": stdout.String(),
        "errmsg": errmsg,
    }
}

wasmが読み込まれた後は、普通のjsの関数と変わりなく使用することができます。

https://github.com/Syuparn/Pangaea/blob/master/web/playground/index.html#L26

web/playground/index.html
function runScript() {
    const src = document.getElementById('source').value;
    const stdin = document.getElementById('input').value;
    const result = pangaea.execute(src, stdin);
    if (result.errmsg !== '') {
        document.getElementById("output").textContent = result.errmsg;
        return;
    }
    document.getElementById("output").textContent = result.stdout;
}

参考:

wasmの読み込み、実行

Go製のwasmを動かすにはwasm_exec.jsが必要なので、公式リポジトリからダウンロードします。念のためバイナリと同じバージョンを利用しています。

web/playground/index.html
<!-- https://raw.githubusercontent.com/golang/go/go1.16.5/misc/wasm/wasm_exec.js をコピー -->
<script src="wasm_exec.js"></script>

後は、wasmのfetch処理の後でgo.run()することで実行されます。

web/playground/pangaea.js
// wasm_exec.js 読み込み
const go = new Go();

// wasmを実行
fetch("./main.wasm").then(response => 
  response.arrayBuffer()
).then(bytes =>
  // 初期化
  WebAssembly.instantiate(bytes, go.importObject)
).then(obj => {
  // 実行(完了すると、`pangaea.execute()`でインタープリターが呼び出せるようになる)
  go.run(obj.instance);
});

参考:

ビルド

リポジトリのGitHub Pages上で公開しています。
wasmのビルドとGitHub PagesへのデプロイはGitHub Actionsで行っています。マージするたびに勝手に更新されるので便利です

https://github.com/Syuparn/Pangaea/blob/master/.github/workflows/deploy_playground.yml

yamlが汚い...

デプロイにはこちらのActionを使用させていただきました。

参考:

詰まったところ

ローカル環境でwasm読み込みができない

初歩的なミスですが、index.htmlをダブルクリックしてもwasmへアクセスできません。ファイルサーバーを立てて確認しましょう。

Fetch API cannot load file:///C:/xxx/Pangaea/web/playground/main.wasm. URL scheme must be "http" or "https" for CORS request.

個人的には python -m http.server 8080 が使いやすくておすすめです。

wasmの謎エラー

pangaea.jsでwasm初期化時にエラー
Uncaught (in promise) CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 21 3c 61 72 @+0

main以外のパッケージからバイナリを生成しようとしたため、wasmではなくオブジェクトファイルが生成されていたのが原因でした。

(21 3c 61 72をasciiで読むと!<arなのですが、何が表示されているのでしょうか...?ご存知の方はコメント欄でご教示いただけるとありがたいです )

mainパッケージはネイティブバイナリのREPL用で既に使っているので、しかたなくwasmパッケージにもgo.modを作り別のmoduleとしました。

初期化が10秒以上かかる

(2021/8/17追記:ボトルネック解消でロードを2~3秒まで縮めることができました)

Pangaeaビルトインオブジェクトのソースコード評価に10秒以上かかるため、その間一切UIが操作を受け付けない状態になってしまいます。

速度を一切無視した弊害が出てきました...ブラウザバックされそう

せめてフリーズはしていないことを伝えられるよう、暫定措置としてロード中にNow loading... (it may take 10 ~ 20s to setup)と表示することにしました。

メッセージ読んでもブラウザバックしますね

おわりに

以上、Go + WebAssembly + GitHub Pagesで自作言語Playgroundを作る方法の紹介でした。皆さんもPlaygroundで自慢の自作言語を布教しましょう!