背景
ruby.wasmというRubyのランタイムをWebAsssemblyにコンパイルした実行環境があります。
これを使うとブラウザでRubyスクリプトを実行出来ます。
実際にいくつかのアプリケーションが作られ、次のページで公開されています。
具体的にブラウザでRubyスクリプトを動かすには、たとえば、次のようにscriptタグにインラインでRubyスクリプトを記述します。
<html>
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.5.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
puts "Hello, world!"
</script>
</html>
または次のようにscriptタグのsrc属性で指定したRubyスクリプトを読み込ませる事も出来ます。
<html>
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.5.0/dist/browser.script.iife.js"></script>
<script type="text/ruby" src="hello.rb"></script>
</html>
現状の課題
アプリケーションが大きくなりRubyスクリプトを複数ファイルにわけたくなった場合はどうしたらいいでしょうか?
複数のscriptタグを使ってRubyスクリプトを順番に読み込むことができます。
<html>
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.5.0/dist/browser.script.iife.js"></script>
<script type="text/ruby" src="lib.rb"></script>
<script type="text/ruby" src="main.rb"></script>
</html>
ファイル数が増えてくると、どの順番で読み込むか考えるのが難しくなります。そのため実質的には2~3ファイルにしか分けられません。2010年代前半のJavaScriptアプリケーションを振り返ると、1ファイルが1000行を超えることは珍しくありませんでした。同様に1つのRubyスクリプトの行数が1000行を越えることが考えられます。
現代のJavaScriptでは、この問題をモジュールバンドラーで解決しています。モジュールバンドラーというのは、たとえばWebPackやViteやEsBuildやRollupです。JavaScriptのソースファイルに記述されたrequire
やimport
という命令によって、JavaScriptファイル間の依存関係を指定します。モジュールバンドラーはこれを手がかりに、JavaScriptのファイル群を上手い順番で並べ替え、1つのJavaScriptファイルに結合してくれます。
解決策
Rubyにもrequire
やrequirue_relative
というファイル間の依存関係を指定する関数があります。
RubyでもJavaScriptのモジュールバンドラーのようにこの問題を解決できないでしょうか?
JavaScriptのrequire
またはimport
関数は、ファイルシステム上のパスとURL上のパスが一致しないことが知られています。
例えば$ = require('jquery')
と書いた時、実際のファイルがどこにあるかは、node_modules
配下のディレクトリを検索しないとわかりません。Rubyのrequire
でも同様にrequire 'csv'
と書いた時に、実際のファイルがどこにあるかはファイルシステムを検索する必要があります。Rubyにおいても、モジュールバンドラーのようなツールを作って、Rubyスクリプトを処理する必要があるのでしょうか?
運良くRubyではrequire
のほかにrequire_relative
関数があります。require_relative
関数は、相対パスで依存するRubyスクリプトを指定します。元々想定しているのはファイルシステム上の相対パスです。これを相対URLと読み替えられるはずです。ブラウザ上で実行するrequirer_relative
を「相対URLに配置されているRubyスクリプトの読み込み処理」に置き換えれぼ、Rubyスクリプトファイルを変換せずにブラウザ上でも実行可能になります。
実現までの課題
コンセプトが出来たので実装してみました。
課題をいくつか発見できました。解決できた課題もあります。解決途中の課題もあります。
解決済み課題
相対パスの解決
相対パスを解決するためには、require_relative
が記述された実行中のRubyスクリプトのURLが必要です。
Rubyスクリプトを実行する際に、そのRubyスクリプトを取得したURLを保存する仕組みが必要です。
これは次のようなデータ構造を作り、実行するRubyスクリプトのURLを保存すると実現できます1。
class EvaluatedScriptStack {
private _vm;
// Stores the URL of the running Ruby script to get the relative path from within the Ruby script.
private _stack: Array<RubyScript>;
constructor(vm) {
this._vm = vm;
this._stack = [];
}
eval(script: RubyScript): void {
this._stack.push(script);
this._vm.eval(script.ScriptBody);
this._stack.pop();
}
}
配列をスタックとして使いRubyスクリプトを実行するたびにURLを保存します。
requrie_relativeを改変すると標準gemの読込ができなくなる
ruby.wasmには標準gemが含まれています。たとえばrequire 'csv'
を実行すればcsv gemが使えます。csv gemの中でrequrie_relative
が使われています。単純にrequrie_relative
を置き換えると、csv gemは使えなくなります。
ruby.wasmに含まれない、scriptタグで読み込んだRubyスクリプトのみrequrie_relative
を置き換える必要があります。
次のように、ダウンロードしたRubyスクリプトを実行前に書き換えることで実現出来ます2。
const patchedScript = scriptBody.replace(
/require_relative/g,
"require_relative_url"
);
ここではrequire_relative
関数をrequire_relative_url
に置き換えています。
解決途中の課題
fetchが非同期関数
WebAssemblyからはHTTPリクエストを送れません。Rubyスクリプトをダウンロードするには、WebAsssemblyの外側の関数を呼びます。ブラウザに組み込みのfetch関数を使います。fetch関数はPromiseを返す非同期関数です。JavaScriptのPromiseそのものはRubyにはありません。Promiseと同様の仕組みで、Promiseの完了を待ち合わせる必要があります。
待ち合わせが出来ない場合は、次のRubyスクリプトを実行すると
require_relative 'lib.rb'
puts 'hello'
lib.rb
を読み込む前にputs 'hello'
が実行されます。依存しているライブラリを読み込み終わってから、スクリプト本体を実行したいです。requrie系の関数は同期的に実行する必要があります。
ruby.wasmには0.5.0からevalAsync
という実行方法があります3。これをつかって次のようにPromiseの完了を待てます。
async functiton slowFunction() {
return 1;
}
rubyVM.evalAsync('JS.global.slowFunction().await')
evalAsync
を使って次のようにJavaScriptの非同期関数を待つrequire_relative_url
関数を定義します。
module Kernel
def require_relative_url(relative_feature)
JS.global[:rubyWasm].requireRelativeURL(relative_feature).await
end
end
そしてJavaScriptのrequireRelativeURL
関数でRubyスクリプトのfetchから実行までを行います。
async requireRelativeURL(relative_feature): Promise<boolean> {
const filename = relative_feature.endsWith(".rb")
? relative_feature
: `${relative_feature}.rb`;
const url = new URL(filename, this._stack.currentURL);
const response = await fetch(url);
if (!response.ok) {
return false;
}
const text = await response.text();
await this._stack.eval(new RubyScript(url, text));
return true;
}
これで上手く行くはずなのですが・・・実際に動かして見るとrequire_relative
を1回呼ぶ分には上手く動きます。
2回呼ぶとPromiseの完了待ちが無限にブロックされます4。この原因がわからず、調査中です。
結果
Advent Calendarの公開日までにはつくりたかったのですが、間に合いませんでした。年明けまでには頑張って動くようにするぞ!
-
https://github.com/ledsun/ruby.wasm/commit/da37a0d0fdf1786537e7616f28692d0e082f2f06 ↩
-
https://github.com/ledsun/ruby.wasm/commit/e72b63fa5527a3d73ea99dd22f377d2596fb250d ↩
-
https://github.com/ruby/ruby.wasm/tree/ruby-head-wasm-wasi-0.5.0/packages/npm-packages/ruby-head-wasm-wasi#evalasync ↩
-
https://github.com/ledsun/ruby.wasm/commit/5e9d234b102c394729971703bc03ed84b629738f ↩