⚙️

Rust製JavaScriptエンジン『Boa JS』を試してみた

2024/06/10に公開

主要なJavaScriptエンジンのTest262を毎日実行して結果を載せているtest262.fyiというサイトがあります。
Test262とは最新のECMAScriptを実装できているかどうかのテストです。)
このサイトの、2024/6/5現在の実装率ランキングはこちらです。


test262.fyiの画面キャプチャ(2024/6/5)

V8(ChromeやNode.js、Deno等)、JavaScriptCore(SafariやBun等)、SpiderMonkey(Firefox等)という、大手エンジンとほぼ横並びで4位に食い込んでいるBoaとは何者でしょうか。

https://boajs.dev/

Boaは公式曰く『Rustで書かれた実験的なJavascriptのレキサー、パーサー、コンパイラー』です。これだけ揃えば、JavaScriptエンジンと言って差し支えないと思います。RustアプリケーションにJavaScript実行環境を組み込むことができます。

現在のバージョンは0.18.0で正式リリース前であることが分かります。正式リリース前だからか、バージョンが上がるごとに使い方も変わってきています。また、大手エンジンに比べて歴史も浅くユーザーも少ないので安全性・信頼性は低いかもしれません。

しかし、Boaは大手エンジンに比べて組み込みやすいという特徴があると私は思います。V8やSpiderMonkeyを使ったことがある方は、いかに導入敷居が高いかご存じだと思います。なんといっても、開発環境を準備することからして難しいです。その点BoaはRustさえあれば、Cargo.tomlに1~数行書くだけで使い始められます。そして、Boaの公式サイトを見ても簡単に組み込めることをアピールしています。

実際にどのように使うのか、試して理解したことを本記事に載せます。この記事によってBoaのユーザーが増えて開発がより進むことを祈っています。

(ちなみにランキング中のKieselはZig製のJavaScriptエンジンで、ランキング1位のLibJSの開発者?が趣味?で作っているようです。こちらも活発に開発されていて注目しています。)

JavaScriptエンジンとJavaScriptランタイム

記事の本編に入る前にJavaScriptエンジンとJavaScriptランタイムの違いを知っておく必要がありますので少し説明いたします。
JavaScriptエンジンというのは、JavaScriptのコードを解析して実行するだけのものです。V8、JavaScriptCore、SpiderMonkeyが該当します。ECMAScriptはJavaScriptエンジンの仕様を標準化したものです。
対してJavaScriptランタイムというのはJavaScriptエンジンを用いてJavaScriptを動かす環境のことです。Node.js、Deno、BunはJavaScriptランタイムです。JavaScriptエンジンとどう違うのかというと、ECMAScriptで実装依存とされて定義されていない部分に加えて、Web APIやイベントループ、JavaScriptコードをJavaScriptエンジンに渡す手段等を提供します。

よってJavaScriptエンジンであるBoaを使うということは、JavaScriptランタイムが無いということです。ECMAScriptで実装依存とされて定義されていない部分、Web APIやイベントループ、JavaScriptコードをJavaScriptエンジンに渡す手段等が無いので自分で実装する必要があります。

Web APIが無いのでMDNのWeb APIに書かれているものは全てありません。イベントループが無いので、then、awaitを書くことはできますが動きません。モジュールについては後述しますがECMAScriptで実装依存とされているところがあるので、それを実装しないと動きません。

とはいえ、JavaScriptエンジンはこれらを実装するための手段を提供しています。本記事で説明するのはBoaにおけるその手段になります。

開発の準備

Boaで開発を始めるにはRustが必要です。私はBoaを試すためにRustをインストールして勉強を始めたばかりです。そのため、Rustのコードはあまり見栄えのいいものではないかもしれませんがご了承ください。

Rustを準備したらCargo.tomlに次のクレートを追加します。

boa_engine = "0.18.0"

Boaには他にもクレートがありますが、最初試すのに必要なのはこれだけです。後は必要に応じて追加してください。他のクレートのバージョンは全て0.18.0に揃えてあります。

Boaのクレートの概要はこちらを参照してください。

https://boajs.dev/docs/intro

概要はあるものの、Boaのドキュメントはまだ充実していません。使い方は主に公式サイトとブログExamplesdocs.rsを見て調べることとなります。BoaはECMAScriptの実装ですので、分からない用語などはECMAScriptや、V8等の他の実装を調べてみると理解が深まるかもしれません。

evalを使ってみる

JavaScriptを実行するにはcontextが必要になります。contextにはevalメソッドがあり、これを使うとJavaScriptのソースを評価して値を取り出すことができます。

例えば次のコードはnew Date()を評価して現在時間のDateオブジェクトを取得しています。

use boa_engine::{Context, Source};

fn main() {
    let js_code = "new Date()";

    let mut context = Context::default();

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}
出力
$ cargo run --quiet
Mon Jun 10 2024 01:01:59 GMT+0900

evalメソッドの結果はJsResult<JsValue>になっていて、match式でJSValueとJSErrorに分けることができます。JSValueは文字列やオブジェクトなど、JavaScriptの値をRustで取り扱うためのものです。

evalメソッドを使えば、BoaでどのようなJavaScriptを実行できるのか、JsValueをRustでどう扱うのか色々と試すことができます。

独自APIの導入

ユーザーが自分でJavaScriptを書いて使えるプラグインのような機能を提供したいときに、アプリケーションの機能をJavaScriptから呼び出せるようにしなければなりません。Boaはこれを簡単に実装できます。

consoleを独自プロパティとして導入してみる

Boaはboa_runtimeクレートでWeb APIのサンプルを提供しています。
・・・と言っても、Consoleしかありません。Consoleも実際にアプリケーションを作るときは独自実装する必要がありますが、動きを確認するのに便利なので使ってみましょう。

use boa_engine::{Context, Source, property::Attribute, js_string};
use boa_runtime::Console;

fn main() {
    let js_code = "console.log('Hello')";

    let mut context = Context::default();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}
出力
$ cargo run --quiet
Hello
undefined

Consoleのインスタンスを作り、register_global_propertyメソッドでグローバルオブジェクトのプロパティとして登録しています。
ここでjs_string!はRustの文字列をJavaScriptの文字列に変えてくれる便利なマクロです。

実行するとHelloとConsole.logの戻り値であるundefinedが出力されます。

独自関数を導入してみる

JavaScript内で独自の関数を利用できるようにすることができます。
今回はメッセージを出力した後に1行入力されるまで待機するalert()関数を作ってみました。

use boa_engine::{js_string, Context, JsArgs, JsResult, JsValue, NativeFunction, Source};
use std::io;

fn alert(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
    // メッセージを取得
    let message = args.get_or_undefined(0);

    // メッセージを出力
    println!("{:?}", message.to_string(context)?);
    
    // 1行入力されるまで待機
    let mut input = String::new();
    io::stdin().read_line(&mut input).unwrap();

    Ok(JsValue::undefined())
}

fn main() {
    let js_code = "alert('OKですか?')";

    let mut context = Context::default();

    context
        .register_global_builtin_callable(js_string!("alert"), 1, NativeFunction::from_fn_ptr(alert))
        .expect("the alert shouldn't exist yet");

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}
出力
$ cargo run --quiet
"OKですか?"
ok
undefined

register_global_builtin_callableメソッドでグローバルオブジェクトのメソッドとしてalertを登録しています。

独自クラス(オブジェクト)を導入してみる

JavaScript内で独自のクラスを利用できるようにすることができます。
ここではURL APIのURLオブジェクトの一部を実装してみます。

最低限クラスとしてふるまうには、Debug, Trace, Finalize, JsData, Classトレイトの実装が必要になります。この内Classトレイト以外はDeriveが使えますので、最初はClassトレイトだけ実装すれば大丈夫です。Traceトレイトについては後述。

まずは空のオブジェクトとしてURLを作ってみます。実行にはboa_gcクレートが必要です。

use boa_engine::{
    class::{Class, ClassBuilder},
    Context, Finalize, JsData, JsResult, JsValue, Source, Trace
};

#[derive(Debug, Trace, Finalize, JsData)]
struct URL {
}

impl Class for URL {
    const NAME: &'static str = "URL";

    fn data_constructor(_this: &JsValue, _args: &[JsValue], _context: &mut Context) -> JsResult<Self> {
        // ここで構造体を初期化する
        Ok(URL{})
    }

    fn init(_class: &mut ClassBuilder<'_>) -> JsResult<()> {
        // ここでプロパティやメソッドを設定する
        Ok(())
    }
}

fn main() {
    let js_code = "new URL()";

    let mut context = Context::default();

    context
        .register_global_class::<URL>()
        .expect("the URL builtin shouldn't exist");

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}
出力
cargo run --quiet
[object Object]

main関数内のregister_global_classメソッドでクラスを登録しています。

ClassトレイトのNAMEはクラスの名前です。
data_constructorで構造体を初期化して返します。
initはオブジェクトのプロパティとメソッドを登録します。
上のコードにはありませんが、LENGTHがコンストラクタが受け取る引数の数になります。

それでは、URLのhostゲッターを実装してみます。なお、Urlを扱うのにurlクレートを使っています。

use boa_engine::{
    class::{Class, ClassBuilder}, js_string, property::Attribute, Context, Finalize, JsArgs, JsData, JsNativeError, JsObject, JsResult, JsValue, NativeFunction, Source, Trace
};
use boa_gc::empty_trace;
use url::Url;

#[derive(Debug, Finalize, JsData)]
struct URL {
    url: Url
}

unsafe impl Trace for URL {
    empty_trace!();
}

impl URL {
    fn get_host(this: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
        // JavaScriptのthisはオブジェクト自身とは限らないので再宣言している
        let this = this
            .as_object()
            .and_then(JsObject::downcast_ref::<Self>)
            .ok_or_else(|| JsNativeError::typ() .with_message("get URL.host called with invalid value"))?;

        Ok(JsValue::from(js_string!(this.url.host_str().unwrap())))
    }
}

impl Class for URL {
    const NAME: &'static str = "URL";
    const LENGTH: usize = 1; // コンストラクタの引数の数

    fn data_constructor(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<Self> {
        // ここで構造体を初期化する
        let text = args.get_or_undefined(0).to_string(context)?;
        let url = Url::parse(text.to_std_string().unwrap().as_str()).unwrap();
        Ok(URL{ url })
    }

    fn init(class: &mut ClassBuilder<'_>) -> JsResult<()> {
        // ここでプロパティやメソッドを設定する
        
        let get_host = NativeFunction::from_fn_ptr(Self::get_host).to_js_function(class.context().realm());

        class.accessor(
            js_string!("host"),
            Some(get_host),
            None,
            Attribute::all()
        );

        Ok(())
    }
}

fn main() {
    let js_code = "new URL('https://boajs.dev/').host";

    let mut context = Context::default();

    context.register_global_class::<URL>().unwrap();

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}

実行するとboajs.devと出力されます。

出力
$ cargo run --quiet
boajs.dev

ここで、Traceトレイトを実装しています。TraceトレイトのDeriveが使えない場合、Traceトレイトの実装が必要になります。Traceトレイトはガーベージコレクションされるオブジェクトに実装する必要があるものです。

実装するオブジェクトが他のガーベージコレクションされるオブジェクトを所有していなければ特に実装する必要はありませんのでempty_trace!マクロを呼ぶだけで問題ありません。

もし、ガーベージコレクションされるオブジェクトを所有している場合、custom_trace!マクロでマークを付けていきます。この仕組みについてはマーク&スイープ法が使われていますので参照してください。

クラスのより詳しい実装方法についてはビルトインクラスの実装が参考になります。

ジョブキュー

先のConsoleのコードで、すぐに解決するプロミスを使ってみます。

use boa_engine::{Context, Source, property::Attribute, js_string};
use boa_runtime::Console;

fn main() {
    let js_code = "Promise.resolve().then(() => { console.log('Hello') })";

    let mut context = Context::default();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };
}

実行すると[object Promise]と出力されるだけで、Helloは出力されません。

出力
$ cargo run --quiet
[object Promise]

evalはコードをただの一度評価するだけなのです。では、thenの中身はどこへいったのかというと、contextが所有しているジョブキューに積まれています。

ジョブキューを使ってみる

次のように、run_jobsでジョブキューに溜まっているジョブが無くなるまで実行することができます。

use boa_engine::{Context, Source, property::Attribute, js_string};
use boa_runtime::Console;

fn main() {
    let js_code = "Promise.resolve().then(() => { console.log('Hello') })";

    let mut context = Context::default();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let result = context.eval(Source::from_bytes(js_code));

    match result {
        Ok(res) => println!("{}", res.to_string(&mut context).unwrap().to_std_string_escaped()),
        Err(e) => eprintln!("Uncaught {e}")
    };

    context.run_jobs();
}
出力
$ cargo run --quiet
[object Promise]
Hello

ジョブを作ってみる

ジョブは自分で作って、ジョブキューに入れることもできます。
contextを受け取ってJsResult<JsValue>を返すクロージャから次のようにジョブ(NativeJob)を作ります。

use boa_engine::{job::NativeJob, js_string, property::Attribute, Context, JsResult, JsValue, Source};
use boa_runtime::Console;

fn main() {
    let js_code = "Promise.resolve().then(() => { console.log('Hello') })";

    let mut context = Context::default();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let job = NativeJob::new(move |context| -> JsResult<JsValue> {
        context.eval(Source::from_bytes(js_code))
    });
    context.enqueue_job(job);

    context.run_jobs();
}
出力
$ cargo run --quiet
Hello

ジョブキューを実装してみる

これまで使ったジョブキューはcontextがデフォルトで使用しているSimpleJobQueueというもので、中身は本当にただのキューになっていて、キューが溜まっている分だけしか実行してくれません。

setTimeoutやfetchのように非同期の処理を実装したい場合、処理中にキューが空になってしまいますのでSimpleJobQueueは使えません。そのため、独自にジョブキューを実装する必要があります。これはになります。

実装方法はJobQueueトレイトの実装になります。examplesのfuture.rsが参考になります。

例では非同期ランタイムにsmolを使っていましたが、ここではtokioを使って、ジョブキューを実装して、指定された秒数待機するsleep関数を作ってみます。

まず、使いたいジョブキューの構造体を作ります。
jobsが溜まっているジョブで、futuresが実行中のFutureです。

struct MyJobQueue {
    jobs: RefCell<VecDeque<NativeJob>>,
    futures: RefCell<JoinSet<NativeJob>>,
}

impl MyJobQueue {
    fn new() -> Self {
        Self {
            futures: RefCell::default(),
            jobs: RefCell::default(),
        }
    }
}

JobQueueトレイトにはenqueue_promise_jobenqueue_future_jobrun_jobsを最低限実装する必要があります。以下にSimpleJobQueueをほぼコピペして実装しました。

impl JobQueue for MyJobQueue {
    fn enqueue_promise_job(&self, job: NativeJob, _context: &mut Context) {
        self.jobs.borrow_mut().push_back(job);
    }

    fn enqueue_future_job(&self, future: FutureJob, _context: &mut Context) {
        self.futures.borrow_mut().spawn_local(future);
    }

    fn run_jobs(&self, context: &mut Context) {
        let mut next_job = self.jobs.borrow_mut().pop_front();
        while let Some(job) = next_job {
            if job.call(context).is_err() {
                self.jobs.borrow_mut().clear();
                return;
            };
            next_job = self.jobs.borrow_mut().pop_front();
        }
    }
}

補足としてFutureJobNativeJobを結果として返すFutureです。FutureJobが返すNativeJobは、Promiseのthenの関数やawaitの後の処理ととらえてよいかと思います。

また、job.call(context)でジョブを実行しています。ここでevalと同じようにJsResult<JsValue>が返ってきますのでエラー処理が可能です。

ここまでだと、溜まったジョブは実行されますが、FutureJobが完了しても後にawaitやthenが動きません。そこで更にrun_jobs_asyncの実装が必要となります。

    fn run_jobs_async<'a, 'ctx, 'fut>(&'a self, context: &'ctx mut Context) -> Pin<Box<dyn Future<Output = ()> + 'fut>>
    where
        'a: 'fut,
        'ctx: 'fut,
    {
        Box::pin(async {
            let local = tokio::task::LocalSet::new();
            local.run_until(async {
                // ジョブとFutureが無くなるまでループする
                while !(self.jobs.borrow().is_empty() && self.futures.borrow().is_empty()) {
                    // 溜まっているジョブを実行
                    context.run_jobs();

                    // Futureの完了を1つ待って終わったら結果(NativeJob)をキューに追加
                    if let Some(res) = self.futures.borrow_mut().join_next().await {
                        context.enqueue_job(res.unwrap())
                    }
                }
            }).await;
        })
    }

これでジョブキューができました。

ジョブキューを指定してcontextを作るには、ContextBuilderというものを使います。また、run_jobs_asyncで非同期でジョブを実行します。
あとは、非同期関数のsleepを作って登録すれば使えるようになります。

fn sleep(_this: &JsValue, args: &[JsValue], context: &mut Context) -> impl Future<Output = JsResult<JsValue>> {
    let delay = args.get_or_undefined(0).to_u32(context).unwrap();
    async move {
        tokio::time::sleep(std::time::Duration::from_millis(u64::from(delay))).await;
        Ok(JsValue::undefined())
    }
}

#[tokio::main]
async fn main() {
    let js_code = r"
        (async () => {
            for (let sec of [1, 2, 3, 4, 5]) {
                await sleep(1000)
                console.log(sec)
            }
        })()
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    context
        .register_global_builtin_callable(js_string!("sleep"), 1, NativeFunction::from_async_fn(sleep))
        .expect("the sleep builtin shouldn't exist yet");

    let job = NativeJob::new(move |context| -> JsResult<JsValue> {
        context.eval(Source::from_bytes(js_code))
    });
    context.enqueue_job(job);

    context.run_jobs_async().await;
}

実行すると1 2 3 4 5と1秒おきに出力されます。

出力
$ cargo run --quiet
1
2
3
4
5

Promiseを直列に実行しましたが、次のように並列に実行しても同様に動作します。

Promise.all([1, 2, 3, 4, 5].map(sec => sleep(sec * 1000).then(() => console.log(sec))))

全体のコードは長くなりますのでここに折りたたんでおきます。

非同期対応ジョブキューのコード
use std::{cell::RefCell, collections::VecDeque, future::Future, pin::Pin, rc::Rc};

use boa_engine::{
    job::{FutureJob, JobQueue, NativeJob},
    context::ContextBuilder, js_string, property::Attribute, Context, JsArgs, JsResult, JsValue, NativeFunction, Source
};
use boa_runtime::Console;

use tokio::task::JoinSet;

struct MyJobQueue {
    futures: RefCell<JoinSet<NativeJob>>,
    jobs: RefCell<VecDeque<NativeJob>>,
}

impl MyJobQueue {
    fn new() -> Self {
        Self {
            futures: RefCell::default(),
            jobs: RefCell::default(),
        }
    }
}

impl JobQueue for MyJobQueue {
    fn enqueue_promise_job(&self, job: NativeJob, _context: &mut Context) {
        self.jobs.borrow_mut().push_back(job);
    }

    fn enqueue_future_job(&self, future: FutureJob, _context: &mut Context) {
        self.futures.borrow_mut().spawn_local(future);
    }

    fn run_jobs(&self, context: &mut Context) {
        let mut next_job = self.jobs.borrow_mut().pop_front();
        while let Some(job) = next_job {
            if job.call(context).is_err() {
                self.jobs.borrow_mut().clear();
                return;
            };
            next_job = self.jobs.borrow_mut().pop_front();
        }
    }
    fn run_jobs_async<'a, 'ctx, 'fut>(&'a self, context: &'ctx mut Context) -> Pin<Box<dyn Future<Output = ()> + 'fut>>
    where
        'a: 'fut,
        'ctx: 'fut,
    {
        Box::pin(async {
            let local = tokio::task::LocalSet::new();
            local.run_until(async {
                while !(self.jobs.borrow().is_empty() && self.futures.borrow().is_empty()) {
                    context.run_jobs();

                    if let Some(res) = self.futures.borrow_mut().join_next().await {
                        context.enqueue_job(res.unwrap())
                    }
                }
            }).await;
        })
    }
}

fn sleep(_this: &JsValue, args: &[JsValue], context: &mut Context) -> impl Future<Output = JsResult<JsValue>> {
    let delay = args.get_or_undefined(0).to_u32(context).unwrap();
    async move {
        tokio::time::sleep(std::time::Duration::from_millis(u64::from(delay))).await;
        Ok(JsValue::undefined())
    }
}

#[tokio::main]
async fn main() {
    let js_code = r"
        (async () => {
            for (let sec of [1, 2, 3, 4, 5]) {
                await sleep(1000)
                console.log(sec)
            }
        })()
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    context
        .register_global_builtin_callable(js_string!("sleep"), 1, NativeFunction::from_async_fn(sleep))
        .expect("the sleep builtin shouldn't exist yet");

    let job = NativeJob::new(move |context| -> JsResult<JsValue> {
        context.eval(Source::from_bytes(js_code))
    });

    context.enqueue_job(job);
    context.run_jobs_async().await;
}

モジュール

これまでの内容で、<script></script>の中身を動かすくらいのことはできるようになったかと思います。
しかし、今どきのJavaScriptなら、import/exportのESModuleは必須と言えるのではないでしょうか。

Boaはモジュール機能も簡単に実装できます。examplesには様々なモジュールを実装する例があります。

これらの例はいきなり読むのは難しい(難しかった)ので順を追ってモジュールを使えるようにしていきます。

モジュールはどんなものか

Boaでモジュール機能を作る前に、Boaのモジュールとはどんなものなのか調べてみます。
ジョブキューの節で最後に作ったコードのmain関数を次のように書き換えてみます。

#[tokio::main]
async fn main() {
    let js_code = r"
        console.log('start')
        await sleep(1000)
        console.log('end')
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    context
        .register_global_builtin_callable(js_string!("sleep"), 1, NativeFunction::from_async_fn(sleep))
        .expect("the sleep builtin shouldn't exist yet");

    let module = Module::parse(Source::from_bytes(js_code), None, &mut context).unwrap();
    let _promise = module.load_link_evaluate(&mut context);

    context.run_jobs_async().await;
}

実行するとstartと出力され、1秒後にendと出力されます。

出力
$ cargo run --quiet
start
end

今まではevalや、ジョブとしてコードを実行していましたが、ここではモジュールとして実行しています。

まず気づくのは、トップレベルawaitが使えるということかと思います。

そして、モジュールのload_link_evaluat関数を実行するとPromiseが返ってきています。なので関数名にevaluateが付いていますがこのタイミングで評価しているわけではないということが分かります。
実際には後のrun_jobs_asyncで動きますので、load_link_evaluat関数はモジュールをジョブキューに突っ込んでいるということになります。

さて、なぜFutureではなくPromiseなのかというと、ECMAScriptにそう書いてあるからです。おそらくトップレベルawaitが関係しているのではないかと思います。
この辺りのことを深く理解するにはECMAScriptを読む必要があります。ECMAScriptのモジュール機能について詳しく解説している2020年の記事がありますので、こちらを読むと理解が進みます(おまけとして書かれているセクションがむしろ本編)。

https://qiita.com/uhyo/items/0e2e9eaa30ec2ff05260

記事の2020年と現在とでは仕様が違いますので完全に理解する必要はありません。

モジュールローダーを実装してみる

先ほどの記事から重要なポイントを引用いたします。

HostResolveImportedModuleは、"./a.mjs"のようなspecifierを解決し、実際のModule Recordを取得・生成する処理です。名前の頭にHostとついているのは、仕様書で具体的な処理を定義しないということを示唆しています。先ほども触れたように、specifierから実際のモジュールを得るためにすべきことは環境によって異なるからです。

HostというのはJavaScriptエンジンを使おうとしている私たちになります。Module RecordというのはBoaのモジュールです。

よって、specifierからモジュールを生成するということを私たちはやらなければなりません。ただ逆に言えば、それさえやればあとはBoaがよしなにやってくれるということです。

では、どうやってやるのかというと、ModuleLoaderトレイトを実装することで可能になります。

ModuleLoaderトレイトで最低限実装しなければならないのはload_imported_module関数です。
文字列のhelloを返すだけのhelloモジュールを作ってみます。

#[derive(Debug, Default)]
struct MyModuleLoader;

impl ModuleLoader for MyModuleLoader {
    fn load_imported_module(
        &self,
        _referrer: boa_engine::module::Referrer,
        specifier: JsString,
        finish_load: Box<dyn FnOnce(JsResult<Module>, &mut Context)>,
        context: &mut Context,
    ) {
        match specifier.to_std_string_escaped().as_str() {
            "hello" => {
                let js_code = "export default 'Hello'";
                
                let module = Module::parse(Source::from_bytes(js_code), None, context);

                finish_load(module, context);
            }
            _ => finish_load(Err(JsNativeError::typ().with_message("module import error!").into()), context)
        };
    }
}

specifierからモジュールを作ってfinish_loadを実行しています。このfinish_loadはECMAScriptのHostLoadImportedModuleにおけるFinishLoadingImportedModuleに該当します。仕様の通り、同期でも非同期でもどちらでも呼び出してOKです。なのでローカルファイルやインターネット上のファイルを非同期で読み込み終わってから実行するのもOKになります。もし非同期で実行するときはFutureJobを作ってジョブキューに入れれば大丈夫です。

では、作ったモジュールローダーをcontextに設定して実行してみます。

#[tokio::main]
async fn main() {
    let js_code = r"
        import hello from 'hello'
        console.log(hello)
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .module_loader(Rc::new(MyModuleLoader))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let module = Module::parse(Source::from_bytes(js_code), None, &mut context).unwrap();
    let _promise = module.load_link_evaluate(&mut context);

    context.run_jobs_async().await;
}

実行するとHelloと出力されます。

出力
$ cargo run --quiet
Hello

このセクションで作った全体のコードは長くなりますのでここに折りたたんでおきます。

helloモジュールを実装したコード
use std::{cell::RefCell, collections::VecDeque, future::Future, pin::Pin, rc::Rc};

use boa_engine::{
    job::{FutureJob, JobQueue, NativeJob},
    context::ContextBuilder, js_string, module::ModuleLoader, property::Attribute, Context, JsNativeError, JsResult, JsString,Module, Source
};
use boa_runtime::Console;

use tokio::task::JoinSet;

struct MyJobQueue {
    futures: RefCell<JoinSet<NativeJob>>,
    jobs: RefCell<VecDeque<NativeJob>>,
}

impl MyJobQueue {
    fn new() -> Self {
        Self {
            futures: RefCell::default(),
            jobs: RefCell::default(),
        }
    }
}

impl JobQueue for MyJobQueue {
    fn enqueue_promise_job(&self, job: NativeJob, _context: &mut Context) {
        self.jobs.borrow_mut().push_back(job);
    }

    fn enqueue_future_job(&self, future: FutureJob, _context: &mut Context) {
        self.futures.borrow_mut().spawn_local(future);
    }

    fn run_jobs(&self, context: &mut Context) {
        let mut next_job = self.jobs.borrow_mut().pop_front();
        while let Some(job) = next_job {
            if job.call(context).is_err() {
                self.jobs.borrow_mut().clear();
                return;
            };
            next_job = self.jobs.borrow_mut().pop_front();
        }
    }

    fn run_jobs_async<'a, 'ctx, 'fut>(&'a self, context: &'ctx mut Context) -> Pin<Box<dyn Future<Output = ()> + 'fut>>
    where
        'a: 'fut,
        'ctx: 'fut,
    {
        Box::pin(async {
            let local = tokio::task::LocalSet::new();
            local.run_until(async {
                while !(self.jobs.borrow().is_empty() && self.futures.borrow().is_empty()) {
                    context.run_jobs();

                    if let Some(res) = self.futures.borrow_mut().join_next().await {
                        context.enqueue_job(res.unwrap())
                    }
                }
            }).await;
        })
    }
}

#[derive(Debug, Default)]
struct MyModuleLoader;

impl ModuleLoader for MyModuleLoader {
    fn load_imported_module(
        &self,
        _referrer: boa_engine::module::Referrer,
        specifier: JsString,
        finish_load: Box<dyn FnOnce(JsResult<Module>, &mut Context)>,
        context: &mut Context,
    ) {
        match specifier.to_std_string_escaped().as_str() {
            "hello" => {
                let js_code = "export default 'Hello'";
                
                let module = Module::parse(Source::from_bytes(js_code), None, context);

                finish_load(module, context);
            }
            _ => finish_load(Err(JsNativeError::typ().with_message("module import error!").into()), context)
        };
    }
}

#[tokio::main]
async fn main() {
    let js_code = r"
        import hello from 'hello'
        console.log(hello)
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .module_loader(Rc::new(MyModuleLoader))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let module = Module::parse(Source::from_bytes(js_code), None, &mut context).unwrap();
    let _promise = module.load_link_evaluate(&mut context);

    context.run_jobs_async().await;
}

モジュールのエラー処理

evalでコードを実行していたときは、JsResult<JsValue>を受け取ってエラー処理をしていました。モジュールの場合は実行するとJsPromiseが返ってきますので、これをRustで処理してエラー処理します。

試しに、未定義のモジュールworldを読み込んで発生したエラーを処理してみます。

#[tokio::main]
async fn main() {
    let js_code = r"
        import hello from 'world'
        console.log(hello)
    ";

    let queue = MyJobQueue::new();

    let mut context = &mut ContextBuilder::new()
        .job_queue(Rc::new(queue))
        .module_loader(Rc::new(MyModuleLoader))
        .build()
        .unwrap();

    let console = Console::init(&mut context);

    context
        .register_global_property(js_string!(Console::NAME), console, Attribute::all())
        .expect("the console object shouldn't exist yet");

    let module = Module::parse(Source::from_bytes(js_code), None, &mut context).unwrap();
    let promise = module.load_link_evaluate(&mut context);

    context.run_jobs_async().await;

    if let PromiseState::Rejected(value) = promise.state() {
        eprintln!("Uncaught {}", value.display())
    }
}
出力
$ cargo run --quiet
Uncaught TypeError: module import error!

promise.state()で現在のPromiseの状態を見ることができますので、Rejectedだったらそれを出力しています。

おわりに

ここまででおおよそのBoaの使い方は説明できたかと思います。あとは、自分が使いたいJavaScript実行環境に達するまでそれぞれの機能をひたすら作りこんでいくだけです。
道のりは長く険しいですが他のJavaScriptエンジンよりは簡単だと個人的には思います。

ぜひBoaを使って自分の欲しいJavaScriptを手に入れてください。

Discussion