23
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RubyAdvent Calendar 2022

Day 13

ruby.wasmでrequire_relativeを使えるようにしたい

Last updated at Posted at 2022-12-12

背景

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のソースファイルに記述されたrequireimportという命令によって、JavaScriptファイル間の依存関係を指定します。モジュールバンドラーはこれを手がかりに、JavaScriptのファイル群を上手い順番で並べ替え、1つのJavaScriptファイルに結合してくれます。

解決策

Rubyにもrequirerequirue_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の公開日までにはつくりたかったのですが、間に合いませんでした。年明けまでには頑張って動くようにするぞ!

  1. https://github.com/ledsun/ruby.wasm/commit/da37a0d0fdf1786537e7616f28692d0e082f2f06

  2. https://github.com/ledsun/ruby.wasm/commit/e72b63fa5527a3d73ea99dd22f377d2596fb250d

  3. https://github.com/ruby/ruby.wasm/tree/ruby-head-wasm-wasi-0.5.0/packages/npm-packages/ruby-head-wasm-wasi#evalasync

  4. https://github.com/ledsun/ruby.wasm/commit/5e9d234b102c394729971703bc03ed84b629738f

23
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?