Deno JavaScriptランタイムを使ってアプリにユーザー拡張機能を加えよう

こんにちは!

今日は、Secutils.devで2024年1月(1.0.0-alpha.5)に導入されたウェブフックの"スクリプト"拡張を例に、アプリケーションにユーザー拡張機能を実装する多くのアプローチの1つについて話したいと思います。簡単に言うと、"スクリプト"拡張は、ユーザーがリアルタイムでウェブフックリクエストを動的に処理し、レスポンスを決めることを可能にします。これにより、シンプルなウェブフックが小さなアプリケーションのように機能します。

ユーザーとして、お気に入りのアプリが少し違った動作をしてくれたらいいのに、と思ったことはありませんか?時には、ほんの少しの動作変更で、頼りにしているアプリやツールに大きな違いを生むことがあります。また、開発者としては、たくさんのユーザー機能リクエストがほぼ同じだが十分には重ならず、全ユーザーを満足させるための単一の機能を実装することなく、カスタマイズ可能な多くのトグルを作ることなくしてはならない状況に遭遇したことはありますか?

これらは修辞的な質問ですが、おそらくこれらのシナリオは少なくとも一度は経験したことでしょう。さもなければ、ブラウザ拡張機能、Shopifyアプリ、Notion統合、Grafana、WordPressプラグインがこれほど人気があるわけがないでしょう。

Secutils.devのソロ開発者として、非常に限られたリソースで運営しており、すべてのユーザーの機能リクエストに応えることができるわけではありません。一方で、限られた事前ユーザーフィードバックに基づき、機能を優先順位付けして開発することは、独自の課題とリスクを持っています。だからこそ、スタートからSecutils.devに何らかの「拡張ポイント」を追加することを検討しました。これにより、ユーザーは必要に応じてユーティリティの特定の振る舞いをカスタマイズできるようになります。

もしも特定の変更がユーザーに実際の価値を持つならば、彼らは自分自身でアプリケーションを拡張するために少し時間を投資することに気にしないでしょう。とくに彼らが正しいツールとドキュメントを持っている場合は。実際には、このことは機能が本当に必要であるという最も効果的な形態の検証となります。時間が経つにつれて、検証されたユーザー拡張機能はメインアプリケーションの機能に取り込まれたり、コミュニティの「拡張機能」マーケットプレイスに登場したりします。

拡張機能の"フレームワーク"選び

そのアイデアは理論上は良さそうです。ただし、すべてのアプリケーションやユーザーに適しているわけではありませんが、ほとんどの開発者が対象の場合は事情が簡単です。開発者は変更やプラグイン、拡張機能に慣れています。さらに重要なことに、彼らにはコーディングスキルがあります。彼らが使用しているアプリケーションを拡張するためのコードを書くことにもっとも快適です。そして、高性能なコード生成言語モデル(LLM)の出現により、将来的には開発者であることが簡単な拡張機能を作成するための厳密な要件ではないかもしれません。

もし私がSectuils.devをユーザーコードで拡張することが良いアイデアだと納得させたら、次に考えるべきことは、このコードの言語です。素晴らしい言語はたくさんありますが、正直なところ、現時点で1つの普遍的な「ウェブ」言語があります。それはJavaScriptです。ユーザーエラーを許容し、簡単に理解できることから、ユーザー拡張機能の理想的な言語と言えます!

もしもあなたのアプリケーションがJavaScriptで書かれているなら、それとJavaScriptの拡張機能を組み合わせるのは当然の流れです。しかしながら、Secutils.devは完全にRustで書かれています。どう進めれば良いのか?幸い私は最近、素晴らしいブログ投稿シリーズを発見しました。それはRustアプリケーションにJavaScriptランタイムをどう実装するかについて述べたもので、それがDenoです:

JavaScriptランタイムを提供するだけでなく、Denoによって、どのAPIおよび機能がユーザーJavaScript拡張機能に利用可能にされるかを私が完全にコントロールすることができます。素晴らしい!

Deno Coreを拡張機能ランタイムとして使用する

ℹ️ 注記: 私は簡潔にするために、コード例の中でいくつかの非本質的な詳細を省略しています。完全なソースコードは、Secutils.dev GitHubリポジトリで見つけることができます。このブログ投稿でDenoが何であり、何でないかについて説明するつもりはありません。興味がある方は公式Denoドキュメントで必要な情報を見つけることができます。

RustアプリケーションにDeno JavaScriptランタイムを組み込むために絶対に必要なものは、deno_coreクレートです。非同期のJavaScriptコードの文字列として表される拡張機能を実行する基本的なコードは次のようになります:

use deno_core::{
  JsRuntime, 
  serde_v8, 
  v8,
  PollEventLoopOptions,
  RuntimeOptions
};
use serde::Deserialize;

/// ユーザースクリプトを実行して結果を返します。
pub async fn execute_script<R: for<'de> Deserialize<'de>>(
   js_code: impl Into<String>
) -> Result<R, anyhow::Error> {
    // JSランタイムの新しいインスタンスを作成します。
    let runtime = JsRuntime::new(RuntimeOptions::default());

    // JSコードの文字列を`ModuleCodeString`に変換し、
    // 結果を取り出します。このスニペットは、`js_code`から
    // のJSコードが非同期であり`Promise`を返すと想定しています。
    // たとえば、以下のようなものです:
    // r#"(async () => {{ return 2 + 2; }})();"#
    let script_result_promise = runtime
        .execute_script("<anon>", js_code.into().into())?;

    // 今、プロミスが解決するのを待ちます。
    let resolve = runtime.resolve(script_result_promise);
    let script_result = runtime
        .with_event_loop_promise(
            resolve, 
            PollEventLoopOptions::default()
        )
        .await?;

    // v8型からスクリプトの結果をデシリアライズして返します。
    let scope = &mut runtime.handle_scope();
    let local = v8::Local::new(scope, script_result);
    serde_v8::from_v8(scope, local)
}

もしRustに慣れているなら、コードは自明です:JavaScriptコードの文字列を取り、Deno/V8が期待する型に変換し、ランタイムにスクリプト実行を指示し、結果のプロミスが解決するのを待ち、次に値を抽出して返します。

実行中のスクリプトに引数を渡すことも可能です。これにはさまざまな方法がありますが、私はスクリプトのグローバルスコープを使用して、スクリプトとの入力パラメータを共有する方法を選択しました:

use deno_core::{serde_v8, v8};
use serde::Serialize;

// パラメータをv8互換型にシリアライズできることを
// 確認してください。
#[derive(Serialize, Debug, PartialEq, Eq, Clone)]
struct ScriptParams {
    arg_num: usize,
    arg_str: String,
    arg_array: Vec<String>,
    arg_buf: Vec<u8>,
}

// パラメータを作成します。
let script_params = ScriptParams {
    arg_num: 1,
    arg_str: "Hello, world!".to_string(),
    arg_array: vec!["one".to_string(), "two".to_string()],
    arg_buf: vec![1, 2, 3],
};

// スクリプトの "scope" を取得します。
let scope = &mut runtime.handle_scope();
let context = scope.get_current_context();
let scope = &mut v8::ContextScope::new(scope, context);

// グローバルスコープにパラメータを保存するためのキーを準備します。
let params_key = v8::String::new(scope, "param").unwrap();
// パラメータ値をv8互換型にシリアライズします。
let params_value = serde_v8::to_v8(scope, script_params)?;
// グローバルスコープに値を設定します(`globalThis.param`)。
context
    .global(scope)
    .set(scope, params_key.into(), params_value); 

不具合や悪意のある拡張機能の取り扱い

完全なJavaScriptランタイム内で動作するJavaScript拡張機能は強力なツールですが、正しく使用されない場合にはかなり害になることもあります。任意のユーザ拡張機能を実行する拡張機能ランタイムを構築するときは、いつか誤用されるかもしれないという前提で動作するのが賢明です。それが意図的に悪意のあるものであるかどうかにかかわらずです。

幸いにも、Deno Coreはデフォルトである程度のセキュリティ保証を提供します:ユーザースクリプトはネットワークやファイルシステムとのやりとりができません(明示的にその機能を露出させた場合を除く)。これにより悪用や攻撃の可能性が大幅に減少しますが、スクリプトはまだCPUとメモリリソースをすべて消費する可能性があり、アプリケーションのサービス拒否(DoS)を引き起こすかもしれません!

例えば、悪意のあるユーザーが次のようなJavaScript拡張機能を提供し、あなたの貴重なサーバーリソースを占有してしまうことが想像されます:

(() => {
    // 無限ループ。
    while (true) {}
})(); 

通常、実行中のユーザー拡張機能に対して実行時間制限を設けるべきです(Secutils.devには30秒に設定)。期間を超えれば"拡張機能プロセス"は終了されます。コードは次のようになるでしょう:

use std::{
    sync::{atomic::{AtomicBool, Ordering}, Arc},
    time::{Duration, Instant},
};

// スクリプトが終了されるタイムアウトを定義します。
let termination_timeout = Duration::from_secs(30);

// メインスレッドがスクリプトの実行が完了したことを
// 終了スレッドに通知し、終了する必要がないことを伝えるための
// "キャンセレーショントークン"を定義します。
let timeout_token = Arc::new(AtomicBool::new(false));

// v8::Isolateのハンドルを取得します。
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
let timeout_token_clone = timeout_token.clone();
std::thread::spawn(move || {
    let now = Instant::now();
    loop {
        // もしメインスレッドがスクリプトの実行が完了したことを
        // 通知したら、終了します。
        if timeout_token_clone.load(Ordering::Relaxed) {
            return;
        }

        // そうでなければ、時間切れの場合は実行を終了するか、
        // 最大2秒間スリープします<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/azasypkin/supercharge-your-app-with-user-extensions-using-deno-runtime-5hck](https://dev.to/azasypkin/supercharge-your-app-with-user-extensions-using-deno-runtime-5hck)