Rustとdeno_coreを使って、独自のJavaScriptランタイムを作ってみる
Node.js、Denoに続いて、Bunという新たなJavaScriptランタイムが登場していますが、Denoの7月29日のブログにて、"Roll your own JavaScript runtime"という記事が投稿されていたので紹介します。
作り方は元記事に載っているのでここでは省略して、掻い摘んだ内容をまとめていきます。
JavaScriptランタイムの材料
- Rust
- deno_core
- tokio
Rustのdeno_core
とtokio
というクレートを使って作っていきます。
deno_core
deno_coreって、もうそれdenoじゃんと思いましたが、こちらのクレートはJavaScriptエンジンであるV8との橋渡し的な役割を持つようです。
deno_coreは、複雑で大量のAPIを持つV8をシンプルに利用できるように、V8インスタンスをカプセル化したJsRuntimeという構造体を提供してくれます。
JsRuntimeは、イベントループを抽象化して実装していますが、ループを駆動させるのはユーザの責任となります。
ちなみに、TypeScriptのサポートなどはありません。
そもそもV8とは何ぞやという方は、こちらの記事が非常に分かりやすかったのでおすすめです。
tokio
非同期アプリケーションを書くための、イベント駆動型ノンブロッキングI/Oプラットフォーム。
特徴としては3つ挙げられていました。
- 速い: ゼロコスト抽象化機能により、ベアメタル・パフォーマンスを実現する
- 信頼性: Rustの特徴を活用し、バグを少なく、スレッドセーフを確保する。
- 拡張性: フットプリントを最小限に抑え、バックプレッシャーやキャンセルを自然に処理できる。
Console API
V8では、Console APIが提供されています。
そのため、特にコードを書かなくてもconsole.log()
などは最初から使うことができるのかと思いましたが、そうでもなかったです。
console.log("Hello, runjs!");
console.error("Boom!");
エラーを出さずに実行できましたが、コンソールに何も表示されませんでした。
ちなみに、Console APIにないものを呼び出すとエラーを吐いたので、ちゃんと用意はされているようです。
console.test("TEST");
error: TypeError: console.test is not a function
runtime.jsに次のように書いて、Rustのメインファイルで読み込ませると実行できます。
(詳細は元記事のAdding the console
APIをご覧ください。)
// runtime.js
((globalThis) => {
const core = Deno.core;
function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}
globalThis.console = {
log: (...args) => {
core.print(`[out]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
};
})(globalThis);
[out]: "Hello runjs!"
[err]: "Boom!"
もちろん、Consoleに独自のものを追加することだってできます。
globalThis.console = {
log: (...args) => {
core.print(`[out]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
test: (...args) => {
core.print(`[test]: ${argsToMessage(...args)}\n`, true);
},
};
console.test("TEST");
[test]: "TEST"
Dino API
新しくDino
というグローバルオブジェクトを追加して、非常に単純なAPI(?)を作ってみました。
(元記事では、filesystem APIを追加しながら説明しています。)
core.opAsync
やcore.opSync
は、JavaScriptとRustの関数を結合するためのものです。ここでは、同期的な処理しかしないのでopSync
しか使っていません。
deno_coreはこれらの引数の文字列にマッチする、#[op]
属性を持つRust関数を見つけ出してくれます。
// runtime.js
globalThis.dino = {
carn: () => {
core.opSync("op_carn");
},
herb: () => {
core.opSync("op_herb");
},
};
// main.rs
#[op]
fn op_carn() {
println!("🦖");
}
#[op]
fn op_herb() {
println!("🦕");
}
また、これらのop
をJavaScriptのコードから利用するには、main.rs
で "extension" を 登録してdeno_core
に知らせる必要があります。
この "extension" を利用することで、Rustのさまざまな関数をJavaScriptに読み込ませることができるようになります。
// main.rs
async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
// 大事なのはここ↓
let runjs_extension = Extension::builder()
.ops(vec![
op_carn::decl(),
op_herb::decl(),
])
.build();
// 大事なのはここ↑
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
extensions: vec![runjs_extension], // ←ここも大事
..Default::default()
});
js_runtime.execute_script("[runjs:runtime.js", include_str!("./runtime.js")).unwrap();
let mod_id = js_runtime.load_main_module(&main_module, None).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(false).await?;
result.await?
}
Dino APIのcarn関数とherb関数をそれぞれ実行した様子です。
// example.js
dino.carn();
dino.herb();
🦖
🦕
Rustの内部にある関数を簡単に呼び出すことができました。
感想
JavaScriptのランタイムなんて、とてもじゃないけど作る気にはなれないし、そもそも作れませんが(bunの作者はすごい...)、簡単なものを少し作って遊んでみる分には面白かったです。
ぜひみなさんも作って遊んでみてください!
ここまで読んでいただきありがとうございました。
成果物
Discussion