🌤️

Serverless Haskell - GHCのWASMバックエンドで Haskell を Cloudflare Workers に載せる

2024/06/22に公開

TL;DR

GHC 9.10 から WASM バックエンド(クロスコンパイラ)が JavaScript FFI に対応したので、Haskell コードを Cloudflare Workers 上で動かしてみたよ。快適に開発するための環境構築・ハック方法と、GHCの出力をCloudflare Workers 向けに修正する方法を紹介するよ。

https://ghc-wasm-worker-demo.konn-san.com

動作画面

https://github.com/konn/ghc-wasm-earthly

はじめに──Asterius から GHC WASM バックエンドへ

GHC は 9.6 から WASM バックエンド(クロスコンパイラ)を搭載していますが、GHC 9.10 から WASM バックエンドが遂に JavaScript FFI に対応しました。
従来から C FFI には対応しており、これを使って Fastly に Haskell からコンパイルした WASM を載せている人などはいました。

https://discourse.haskell.org/t/haskell-via-webassembly-on-fastly/5937

Cloudflare Workers は API を JavaScript 経由で提供しており、GHC 9.10以前では同様の事をするのは非常に困難でした。
一方、Asterius は JavaScript FFI をサポートしており、Stack Builders や Tweag の人々は既にこちらを使って Cloudflare Workers で Haskell を動かしていました:

https://blog.cloudflare.com/cloudflare-worker-with-webassembly-and-haskell

https://www.tweag.io/blog/2020-10-09-asterius-cloudflare-worker

Asterius は GHC を fork して作られた WASM へのコンパイラで、現在の GHC WASM バックエンドの前身にあたるもので、開発者も共通しています。
一方で、現在の GHC の WASM バックエンドと Asterius とでは、FFI の仕様や Template Haskell の実現方法、ランタイムシステムの構造、コンパイルの方法などに結構大きな差があります。
これは、同時期に GHC に(ghcjsを前身とする) JavaScript バックエンドがマージされたりしていたり、独立して開発する上で望ましい実装法と、GHC 本体に他のバックエンドと共存する形でクロスコンパイラとしてマージする上で望ましい実装とは異なるためです。

なので、「先達が Asterius で実現出来ており、最近 GHC 9.10 の WASM バックエンドに JS FFI が入ったから Cloudflare Workers で Haskell が動く筈だぞ」というのは推測として妥当で、まあだから今回やろうと思った訳ですが、それを実現する過程は結構非自明なことが多い訳です。

そこで、本記事では、GHC WASM バックエンドが吐いたコードを Cloudflare Workers で動かすに当って必要だった知見を共有したいと思います。

解決すべき課題

GHC WASM バックエンドで Cloudflare Workers で動くコードを吐くに当たっては、解決すべきが大きく分けて二つあります。
それは、GHC WASMバックエンドで快適に開発するための環境整備(ツーリング) と、WASMバックエンドの出力をCloudflare Workers で動かすためのハックです。

ツーリング:IDEとビルド環境の用意

前者のツーリングの問題は、WASM バックエンドをCloudflare Workers 以外の用途に使いたい場合にも有効で、たとえばブラウザで動くウェブアプリを作りたい場合なども共通して使えます
たとえば、以下のレポジトリは私がお芝居の稽古中に遠隔で音出しをするために GHC WASM バックエンドを使ってつくった簡単な(ローカルネットワークでの利用を想定した)ウェブアプリですが、これの開発にも今回紹介する開発環境を使っています。

https://github.com/konn/soundbooth

トグルで曲が流れる。
ボタンを押すと曲が本体から流れたりフェードアウトしたりする。Miso製。

ツーリングを解決する上で考えないといけないのは、以下の二点です:

  1. ビルド環境をどう用意するか?
  2. IDEを使いたい!

ビルド環境

(1) のビルド環境については、そもそもどういったプラットフォームで開発をしているのかに依存します。私の場合は ARM Mac を開発機として利用しているため、こいつの上で動かす必要があります。
公式の ghc-wasm-metaレポジトリでは Nix の利用が推奨されています。上記音出しシステムの開発上で大いに参考にした Tweag の人達の GHC WASM + Miso のデモコードでも Nix をビルドシステムとして採用していました:

https://github.com/tweag/ghc-wasm-miso-examples

しかし、私はNixをあんまり使ったことがなく、ためしに導入してみましたが macOS 上で上記の指示に従っても WASMバックエンドをインストールすることができませんでした。Nix はストレージ容量を圧迫するのもやや不安です。

なので、ちょっと今回は Nix の利用は諦めたいと思います。一方、最近の GHCup は cross リリースチャンネルで試験的にクロスコンパイラのインストールをサポートしています。

https://www.haskell.org/ghcup/guide/#ghc-wasm-cross-bindists-experimental

これを使えばクロスコンパイラを手軽にインストールできそうです。しかし、残念ながら執筆時点では macOS 向けの GHC WASM バックエンドは GHCup から入手可能ではないようで、ghc-wasm-bindistsレポジトリから入手できそうな ARM Mac 向けのbindistも上手く入れられませんでした。
そこで、今回は Linux コンテナ上に GHCup 経由でクロスコンパイラをインストールして WASM ビルド環境を整備することにしました。お誂え向きに、Earthly というDocker と Makefile のキメラのようなビルドシステムがあったので、これを採用します:

https://earthly.dev

Earthly は BuildKit を使って構築されているので、macOS 上で動かすには何らかのコンテナランタイムが必要です。Linux 上で GHCup がインストールできる WASM クロスコンパイラは x86_64 のものだけでしたので、x86_64 のコンテナも対応していてそこそこ速いらしい OrbStack を今回は使いました:

https://orbstack.dev

Earthly向けに作ったビルド環境イメージ自体は、OCIコンテナに対応していれば他の環境でも使え、以下から pull できます[1]

https://github.com/users/konn/packages/container/package/ghc-wasm-earthly

これらを使って、以下のような Earthfile(ビルドスクリプト)を用意しました:

https://github.com/konn/ghc-wasm-earthly/blob/main/Earthfile

Earthfile は関数としてパラメトリックにビルドステップを抽象化できるので楽です。
上の Earthfile は ghc-wasm-miso-examples のビルドスクリプトを参考にしつつ、以下のような処理を行います:

  1. WASM用GHCとCabalでプロジェクトをビルドする
  2. WASMを最適化・プレ初期化する
  3. JSFFI 用のグルーコードを生成し、必要に応じて Cloudflare Workers 用に書き換える
  4. Workers ターゲットの場合、テンプレートに従ってプロジェクトディレクトリを生成し、必要な node_modules をインストールする

たとえば、記事冒頭で紹介した Workers をビルドするには以下のようにします:

earthly +hello-cf

すると、Earthly がコンテナを立ち上げ、自動的にビルドが走り、_build 以下に結果が出力されます。
これでビルド環境はひとまず確保できました。

IDEと簡易ビルド環境の用意

ビルド環境はできましたが、私はもう Haskell Language Server なしには生きられない身体なので、ちゃんと HLS を使えるようにしたいところです。
しかし、WASMバックエンドはインタープリタを搭載していません。HLS は本質的には重武装した GHCi なので、WASMバックエンド向けに HLS をビルドすることはそもそもできない訳です。

なので、HLS自体は手元の環境のふつうの GHC 向けの奴を使うことにするのがよさそうです。
コードベースが JavaScript FFI を含まない(C FFI なら大丈夫)場合は、GHCのバージョンを十分あわせ、使うライブラリのバージョンをちゃんと固定すれば、単純に手元のふつうの HLS、GHC、Cabalを使って開発・デバッグをし、WASMへのコンパイルだけ Earthly を使ってコンテナ内ですれば大丈夫です。

しかし、JavaScript FFI を使おうと思った場合、この方法には少し問題があります。
というのも、JavaScript FFI は JavaScript バックエンドと WASM バックエンドでしか有効化されておらず、通常のGHCではWASMのJS向け型が定義されている GHC.Wasm.Prim モジュールも利用できないためです。

HLSが通常のGHCとしか使えないので、JSFFI を使うコードを開発したい場合はこの点を回避するハックが必要になります。

GHC.Wasm.Prim の不在については、WASM 向けでないコンパイラの場合だけ同様のインタフェースを提供するダミーモジュールを用意すれば問題なさそうです:

https://github.com/konn/ghc-wasm-earthly/blob/main/ghc-wasm-compat/src-compat/GHC/Wasm/Prim.hs

JS関連の型を扱う関数(toJSString など)については、単純に型検査だけができればいいので実行時エラーになるダミー関数でよいでしょう。

問題は JavaScript FFI です。JSFFI はこんな感じのみためです:

foreign import javascript unsafe "console.log($1)"
  consoleLog :: JSString -> IO ()

C FFI との違いは、import の後が ccallcapi ではなく javascript になっている点です。
WASM や JS Backend 以外のコンパイラにこうしたコードを食わせると、以下のようなエラーが出てしまいます:

The `javascript' calling convention is unsupported on this platformWhen checking declaration:
    foreign import javascript unsafe "console.log($1)"
      consoleLog :: JSString -> IO ()

どうすればいいでしょうか?まず思い付くのは、直接書いたらだめならマクロを使えばいいのでは? という事です。以下のような感じで書ける Template Haskell マクロ javaScriptFFIImport を用意したらどうでしょうか?

{-# LANGUAGE TemplateHaskell #-}
javaScriptFFIImport Unsafe "console.log($1)" 
  [d| consoleLog :: JSString -> IO () |]

javaScriptFFIImport は、WASMバックエンドでコンパイルした場合は直接上の foreign import にそのまま展開され、それ以外の場合は以下のようなダミーの関数定義を生成します:

consoleLog :: JSString -> IO ()
consoleLog = error "foreign import javascript unsafe \"console.log($1)\" consoleLog :: JSString -> IO ()"

同様に、foreign export javascript 宣言に対しては、WASMバックエンドならママとし、それ以外では何も出力しないようなマクロを用意すればよさそうです。

しかし、残念ながら GHC 9.10 の時点では WASM バックエンドは Template Haskell をサポートしていません。これは、先にも述べたように GHC WASM バックエンドがインタープリタを搭載していないためで、Template Haskell はコンパイル時にインタープリタを呼び出して実行されるためです。
これはさすがに不便なので、近い将来は適切な外部インタープリタを使えるようにすることで Template Haskell をサポートする予定のようですが、現時点では使えないということです。

今回やりたいことは、結局 WASM 以外のターゲットでは JS FFI 定義を書き換えたい、ということです。そこで、今回は GHC Source Plugin を実装することにしました。GHCはコンパイラプラグイン機構を提供しており、パーズ完了後、リネーム後、型検査中、などさまざまなタイミングで GHC のパイプライン入出力をフックしたり書き換えたりすることができます。
つまり、上のような書き換え(foreign import のダミー宣言への置き換えと export の無条件削除)を行う Source Plugin を実装してそれを呼び出すようにしようという訳です。一点留意が必要なのは、コンパイラプラグインもコンパイル時にインタープリタを使って実行されるという点です。なので、無条件に有効化しては WASM バックエンドでは動きません。しかし、コンパイラプラグインは GHC の -fplugin オプションで指定するものなので、cabal ファイルでターゲットに応じて必要な場合だけ有効化することができます。こんな感じに:

library
  if !os(wasi)
    build-depends: ghc-wasm-compat
    ghc-options: -fplugin GHC.Wasm.FFI.Plugin

GHC の WASM バックエンドは、アーキテクチャが wasm32、OS が wasi として扱われています。なので、このように指定することで、WASMバックエンドではないときだけに限りコンパイラプラグインを有効化できるわけです。

こうすれば、JSFFI を含むコードベースも手元の環境でふつうの GHC / Cabal を使って問題なくコンパイルが出来るようになり、コンテナ外のネイティヴの速度で型検査が通るか確かめることができるわけです。
ただし、JSFFI で import/export できる型には制限があり、その型のデータ構築子がスコープにある必要がありますが、このコンパイラプラグイン GHC.Wasm.FFI.Plugin ではその点は検査していません(やりゃできるが面倒くさいので)。なので、手元で通ったとしても WASM バックエンドで必ずコンパイルが通る訳ではなく、あくまで近似です。とはいえ、これだけでもかなりの部分エラーを弾け便利です。

こうすれば HLS も JSFFI のあるコードを扱える……といいたい所ですが、HLS は Source Plugin と食い合わせが完全にはよくないようで、開いているコードやその依存するモジュールが JSFFI を呼び出している場合、Source Plugin が有効化されるタイミングが遅れるのか、先に挙げた The `javascript' calling convention is unsupported on this platform エラーの偽陽性が出てしまうことがしばしばあります[2]偽陽性のエラーが出たモジュールに飛ぶと消えるか、消えなくても無駄な編集・保存をするとエラーが消えてくれる場合が多く、unsupported convetnion エラーが出たら片っ端からモジュールに飛ぶ&偽保存を繰り返す内にだいたい終息してくれます。終息してくれない場合もありますが、そういう場合はHLSを再起動して同じことを繰り返せば終息してくれます。

これは完全に快適とは正直いい難い状況ですが、それでも無いよりはマシなので一旦これで満足することにします。Source Plugin と HLS の相性については、ちょっと調査してそのうち issue を作りたいと思ってます(なんかそういう issue すでにあった気がするけどちょっと見付からなかった)。

とにもかくにも、これで最低限のビルド&IDE環境は整備できたので、よしとしましょう。

Cloudflare Workers と GHC WASM バックエンドのすりあわせ

ツールは済んだので、GHC WASM バックエンドの出力を Cloudflare Workers で動かす上でひっかかりポイントを見ていきます。
ここで紹介するもののうち、ワーカのラップスクリプトや GHC が生成する JSFFI 用グルーコードのパッチ当てについては、既に Earthfile のロジックに組み込まれています。

Cloudflare に限らない軽い落とし穴: wrappers import は常に IO に包むべし

いきなり最初に Cloudflare に関係ない一般的な落とし穴の話をします。

GHC Users Guide にあるように、WASM バックエンドでは特別な "wrapper" import という方法があり、Haskell のコールバック関数を JavaScript 側で使える型に変換する機能があります:

https://downloads.haskell.org/ghc/latest/docs/users_guide/wasm.html#foreign-exports

こちらは Cloudflare Workers の Fetch Handler の例です:

type FetchHandler = 
  Request -> Env -> Context ->  IO Response

-- NOTE: Users Guide の通り、ここの unsafe/safe は返値の純粋性には無関係で、sync/async に対応する。
-- これは C FFI の非同期例外に対する安全性の区別からきている。
foreign import javascript unsafe "wrapper"
  toJSFetchHandler :: FetchHandler -> IO JSFetchHandler

ここでは、toJSFetchHandler が Haskell 側の関数を、Cloudflare Workers で使う JavaScript 側の Fetch 関数の型に自動的に変換するものとして定義されています。
返値の JSFetchHandlerIO に包まれているのが、実はかなり大事です。一回しか使わないからとこの IO を外してしまうと謎のエラーに悩まされることになります。

-- IO をなくしてはだめ!!!!!
foreign import javascript unsafe "wrapper"
  toJSFetchHandler :: FetchHandler -> JSFetchHandler -- IO に包め!!!!!

こうしてしまうと、実行時のかなり早い段階で getJSVal(123456) という例外が発生し強制終了してしまいます。
getJSVal は JS FFI でやりとりをしている JavaScript 値を GHC の JS 向けランタイムが管理しているテーブルから取得するための(ユーザは使わない)内部関数です。具体的な原理はわかりませんが、IO を外すと実行順が保証されないためか、まだ初期化されていないはずの JavaScript 値を見にいってしまい、奇っ怪なエラーに陥るようです。
最初このエラーが出たときはまさかそんな原因だとは思わなかったので、数時間頭を悩ませてもう一度マニュアルとコードを比較しながら読んで漸くこの可能性に思い当たり、修正した所無事動くようになりました。

GHC Users Guide にははっきりと記述がないように思いますが、 "wrapper"インポートの際には必ず返値を IO に包む ということを徹底しましょう。

GHC の生成する FFI グルーコードの修正

あとはコンパイル後の出力を調整し、WASMを読み込むラッパースクリプトを実装すれば、Cloudflare Workers で動くようになります。
まずは、コンパイラ出力の調整を見ていきましょう。

厳密には、書き換える必要があるものはコンパイラ出力ではなく、post-linker.mjs が生成する JS FFI 用のグルーコード ghc_wasm_jsffi.js です。
細かい役割や生成方法は GHC Users Guide で解説されていますが、簡単に言うと ghc_wasm_jsffi.js は WASM 側から呼び出すための関数の定義や、GHCのWASMランタイムシステムと JavaScript 世界を両立させるためのグルーコードが含まれています。

ghc_wasm_jsffi.js は node や deno, bun などのランタイムやブラウザ上のどちらでも動くように最低限の polyfill コードも含まれていますが、Cloudflare Workers で使える node 処理系のAPIは(意図的に)されているため、polyfill もそのままでは作動しません。

具体的には以下の三つの部分に対処する必要があります:

  1. setImmediate 関数の不在
  2. MessageChannel クラスの不在
  3. FinalizationRegistry クラスの不在

三つといいましたが、ghc_wasm_jsffi.js では (2) の MessageChannelsetImmediate の polyfill を提供するために用いられているので、setImmediate の代替を提供できれば MessageChannel そのものは必要ありません
ghc_wasm_jsffi.jssetImmediate の polyfill は以下の部分です:

class SetImmediate {
  #fs = [];
  #mc = new MessageChannel();

  constructor() {
    this.#mc.port1.addEventListener("message", () => {
      this.#fs.pop()();
    });
    this.#mc.port1.start();
  }

  setImmediate(cb, ...args) {
    this.#fs.push(() => cb(...args));
    this.#mc.port2.postMessage(undefined);
  }
}

// The actual setImmediate() to be used. This is a ESM module top
// level binding and doesn't pollute the globalThis namespace.
let setImmediate;
if (globalThis.setImmediate) {
  // node.js, bun
  setImmediate = globalThis.setImmediate;
} else {
  try {
    // deno
    setImmediate = (await import("node:timers")).setImmediate;
  } catch {
    // browsers
    const sm = new SetImmediate();
    setImmediate = (cb, ...args) => sm.setImmediate(cb, ...args);
  }
}

MessageChannel が使えないので最初の SetImmediate クラスの定義がエラーになり、そこを回避しても node:timersglobalThis.setImmediate も存在しないので、このままでは動きません。
これは、この部分を単純に次の定義で置き換えることで解決できます(このハックはこのgistで見付けました):

const setImmediate = (fn) => setTimeout(fn, 0);

最後の FinalizationRegistry は、JS側に貸し出した Haskell のオブジェクトを JavaScript 側の GC に管理させるのに使っています。長時間動作させる場合はこうした処理をしないとメモリが溢れてしまいますが、Cloudflare Workers はどうせミリ秒単位しか動かないのでこれを無効化しても大して問題ありません。
そこで、何もしない FinalizationRegistry を定義してしまえば問題は解決です:

class FinalizationRegistry {
  constructor(_callback) {}
  register(... args) { return; }
  unregister(..._args) { return 1; }
}

こうした手法は、先述した Asterius on Cloudflare Workers の記事でとられていた「時間節約のために GC そのものを無効化する」という方法とパラレルだと思います。あちらの記事では、Asterius のランタイムは毎回JS の GC に回収されるということを根拠に Asterius の YOLO (You Only Live Once) モード で Haskell RTS の GC 自体を無効化しています。GHC WASM バックエンドには YOLO モードはなく、同じように GHC の GC を止めるのは RTS オプションをものすごく緩いものにするなどが必要そうですが、精神的には同じ発想です。
いずれにせよ、偉大な先達が GC を無効化する、ということをしているので、我々も部分的に GC をやめてしまっても問題はないでしょう。

以上のパッチを当てるスクリプトは以下で、Earthly で呼び出しています:

https://github.com/konn/ghc-wasm-earthly/blob/main/cloudflare-worker/data/jsffi-patcher.mjs

最後の一歩:Cloudflare Workers のスクリプトから GHC の吐いた WASM を呼び出す

あとは Cloudflare Workers の JavaScript モジュールから GHC の出力した WASM モジュール(の最適化後版)と上でパッチを当てた ghc_wasm_jsffi.js を読み込んで呼び出してやれば、Workers 上で GHC が生成した WASM が動きます!

GHC の Users' Guide によれば、WASM モジュールにはコマンドモジュールとリアクタモジュールの二種類があるようです。コマンドモジュールはCLIで一発実行する一つのエントリポイントだけを露出するのに対して、複数のエントリポイントを露出するのがリアクタモジュールで、WASM バックエンドでJSFFIを利用する場合はリアクタモジュールを出力する必要があります。

Users Guide には、GHC + Cabal で WASM のリアクタモジュールを生成し、WASI 文脈を与えて初期化し JS から呼び出す方法は詳細な解説があります。

以下では、こんな感じで handlers が Haskell から外に向けて export されているとします:

foreign export javascript "handlers" handlers :: IO JSHandlers

handlers :: IO JSHandlershandlers = toJSHandlers Handlers {fetch}

これを WASM バックエンドでリアクタモジュールにコンパイルするには以下のように cabal ファイルでオプションを指定します:

executable hello-worker
  import: defaults
  build-depends: cloudflare-worker
  main-is: Main.hs
  hs-source-dirs: app
  ghc-options: -O2

  if os(wasi)
    ghc-options:
      -no-hs-main
      -optl-mexec-model=reactor
      "-optl-Wl,--export=handlers"

Cloudflare Workers も WASI を使うためのライブラリ @cloudflare/workers-wasiを公式が提供しているので、以下のようにすればこうして吐き出されたWASMモジュールを Workers の JavaScript モジュールから呼び出してやれます:

import { WASI } from '@cloudflare/workers-wasi';
import ghc_wasm_jsffi from './ghc_wasm_jsffi.js';
import wasm_module from './handlers.wasm';

const wasi = new WASI();
const instance_exports = {};
const instance = new WebAssembly.Instance(wasm_module, {
  wasi_snapshot_preview1: wasi.wasiImport,
  ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports),
});
Object.assign(instance_exports, instance.exports);

await wasi.initialize(instance); // !

const handlers = await instance.exports.handlers();
export default handlers;

WebAssembly.Instance の返値の型がちょっと違うところなどを除けば、GHC Users Guide の記述そのままで呼び出してやることができました!……といいたい所ですが[3]、一点問題があります。それは、@cloudflare/workers-wasiはリアクタモジュールを初期化する関数wasi.initialize()を提供していないことです。
一方、コマンドモジュールを実行する wasi.start() 関数は @cloudflare/workers-wasi でも提供されています。
この制限は、リアクタモジュールにダミーのメイン関数(仕様上は _start)を追加して、コマンドモジュールに偽装してwasi.start() に渡して初期化をすることで回避できます(このハックはこのコードから盗みました)。

// wasi.initialize(instance); のかわりにコマンドモジュールに偽装する
await wasi.start({ exports: { _start() {}, ...instance.exports } });

完全な JavaScript コードは以下です:

https://github.com/konn/ghc-wasm-earthly/blob/main/cloudflare-worker/data/worker-template/src/worker.js

この worker.js と同じ階層に生成された WASMモジュールと ghc_wasm_jsffi.js を配置して適切に設定・デプロイしてやれば、全て完了です!やりました!🎉

おわりに

WASMバックエンドはまだまだ発展途上で足りない機能もあるとはいえ、それでも十分実用に耐え得るようになっているのが印象的でした。開発者の皆さん(というか TerrorJack さんが殆んど一人でやっている?)には本当に頭が下がりますし、先達の試行錯誤にも経緯を表したいと思います。

WASMバックエンドはまだ Template Haskell は使えませんが、Tweag の人達が依存パッケージを TH に依存しないように書き換えたものなどを公開してくださっており、aesonlens も問題なく使えたのでとても快適でした。
上では HTML の生成に Lucid2 を使っているくらいですが、冒頭に上げた Tweag の Asterius の記事では Servant でルーティングをする話などがあったので、盗んでいきたいと思います。今回はかなり単純なアプリでしたが、複雑になってくるとGCの時間やRTSの分のWASMの容量が Workers の制限内に収まるのかも気になるところです。

WebIDL について

また、余談ですが、Cloudflare Workers のAPIは一般的な Fetch API の拡張として定義されています。なので今回は先に Fetch API まわりのバインディングを生成するところから始めました。こうした API は WebIDL で記述されており、Rust の web-sys などもこれらから低水準のバインディングコードを生成しています。

https://webidl.spec.whatwg.org
https://rustwasm.github.io/wasm-bindgen/api/web_sys/

また、ghcjs 時代から存在するマルチプラットフォームの JavaScript ライブラリである jsaddle のDOMインタフェースバインディング jsaddle-dom も WebIDL からコード生成をしているようです。

https://hackage.haskell.org/package/jsaddle-dom

なので jsaddle-dom のコード生成器を流用できればよかったんですが、どう動かせばよいのかいまいちわからなかったのと、jsaddle はクロスプラットフォームを実現するために直接 FFI を呼び出すのではなく、JSコードの生成手続きを生成して、それを各文脈(GHCのJSバックエンド、WASMバックエンド、サーバ+WebKitブラウザ, etc.)でevalするという戦略をとっています(私の理解では)。

WASM Backend の JS FFI で直接呼び出せるようにしたかったのもあり、今回はゼロから WebIDL のコード生成器を自分で書くことにしました。それが以下のパッケージで、結果が web-sys-hs です。

https://github.com/konn/ghc-wasm-earthly/tree/main/webidl-codegen-wasm
https://github.com/konn/ghc-wasm-earthly/tree/main/web-sys-hs

Hackage にはいくつか WebIDL のパーザが上がっていましたが、いずれもちょっと古い仕様に準拠しているようで、Rust の web-sys に同梱されている WebIDL をパーズできませんでした。
なので、今回は WebIDL のパーザを実装するところから始めています:

https://github.com/konn/ghc-wasm-earthly/tree/main/webidl-core

基本的には上掲の WebIDL の公式仕様を基に実装したんですが、結構仕様にアヤフヤな部分があったり(Extended Attribtues の仕様の記述まじで適当じゃない?)、web-sys 同梱の WebIDL (やその元となっている MDNのものも?)ではまだ廃止された記法(Constructor Extended Attribtue など)が残っていたりするので、そのあたりに対応するのが結構面倒でした。また WebIDL の中に未定義のインタフェースが現れたりするのもあり、その辺のダミーコードを書いてやる必要もありました。
また、今回 WebIDL のオブジェクト階層を表現するのに採用した形式化だと、辞書の中に自分自身の型を参照するようなフィールドがあると上手く定式化できねえ!というのに最後の方に気付いたので、その辺の定義は今回一旦除外して、Workers で必要な Request / Response / WebSocket に必要な部分についてだけコード生成しています。
以下のYAML(と WebIDL ファイル群)から現在では1100くらいのモジュールが生成されています;

modulePrefix: "GHC.Wasm.Web.Generated"
outputDir: "./src/GHC/Wasm/Web/Generated"
inputDir: "./webidls"
extraImports:
- "GHC.Wasm.Web.Types"
predefinedTypes:
- ArrayBufferView
- BufferSource
- DOMTimeStamp
targets:
- Request
- Response
- WebSocket

GHC + WASM の未来

というわけで、GHC 9.10 の GHC WASM Backend で吐いたコードを Cloudflare Workers で動かす上の注意点等を紹介しました。Template Haskell やコンパイラプラグインなどが WASM バックエンド上でも使えるようになれば、更に色々な可能性が開けると思います。今回は紹介しませんでしたが、コードの一部では Linear Types なども使っていて、問題なく動いています。Workers 向けのWASMではGCを抑制する代わりに Linear Haskell でリソース管理をする、というのは戦略としてけっこうアリっぽいので、今後検討していきたいです。FFI は Linear Types が輝く場所でもあるので。

今回は Workers に焦点を絞りましたが、普通にブラウザアプリも作れるくらいには成熟していて、こういった面でもどんどん利用していきたいなと思います。そのためには Nix を使わずとも手軽に色々なプラットフォームで WASM のクロスコンパイラが使えるようになると利用が広がるんじゃないでしょうか。今回はEarthlyを使いましたが、他にも色々なやりようがあると思うので、知見を持っている人がいたら共有してほしいです。手元の環境で直接入るなら、それこそ Shake でビルドシステムを作ったほうがいいかもしれない。

そんな感じで。Happy Working Haskell-WASM!

脚注
  1. 他の人も色々イメージを作っているので、敢えて私のものを使う意味はあんまりないかもしれません(カスタムでイメージを用意したのは最初は devcontainers を使おうとしていたからです)。 ↩︎

  2. 完全な余談:高校時代に通っていた予備校のインチキ英語教師が「often を "しばしば" って訳す奴いるけど、しばしばなんて言葉普通使わないんだからこんな訳語使っちゃだめだよ」と宣っていたが、このように私は「しばしば」をしばしば使うので、ナニイッテンダコイツとなり、その怒りは15年以上を経た今も継続しています。(まじで完全な余談) ↩︎

  3. こいついつもといいたがってんな。 ↩︎

Discussion