🦔

ruby.wasmを簡単なモックAPIとして利用してみる

に公開

先日、愛媛県松山市で開催されたRubyKaigi 2025に参加してきました。
そこで「ruby.wasm」という技術を知りました。
このruby.wasmにもっと親しむために、簡単なモックAPIとして活用してみるということを試してみたので、今回はその取り組み内容についてまとめています。
※少々前置きが長くなるので、ここから読み進めていただいても問題ありません。

ruby.wasmとWebAssembly

(自分の理解度に比例して説明も粗くなっています…)
ruby.wasmとは、一言で言うと「ブラウザ上で動くRuby」です。
開発者である@kateinoigakukunさんの発表をまとめた以下の記事がわかりやすかったです。
https://logmi.jp/main/technology/327678

私も日々Rubyを扱っており、普段は何気なくRubyのコードを実行したり、irbで色々試したりしています。しかし、こうして手元のマシンでRubyを実行するには、Rubyインタプリタをインストールする必要があります。ところが、Rubyインタプリタはどこでも簡単にインストールできるわけではなく、WebブラウザやモバイルOSなどでは導入が難しいとう側面があります。
またRubyインタプリタのインストール自体も、初心者にとってはなかなかハードルが高く、私自身も過去に何度かつまずいた記憶があります…。

さて、ruby.wasmの背後には「WebAssembly(WASM)」という技術があります。
以下は、レバテックさんのコラム記事からの抜粋です。

WebAssembly※は、第4のWeb言語と称される、Webアプリにおけるフロントエンド高速化の仕組みです。フロントエンドの世界では、HTML、CSS、JavaScriptを使っての開発が一般的ですが、その4番目の言語がWebAssemblyというわけです。(略)
(※):AssemblyをASMと略することから、WebAssemblyはWASMと呼ばれることもあります。

https://levtech.jp/media/article/column/detail_484/

ブラウザ上で動作するJavaScriptは手軽で便利である一方で、重たいタスクは苦手で、即時性や実行速度の面に課題がありました。こうした中、「ブラウザでも高速でプログラムが実行したい」というニーズからWebAssemblyが登場しました。
その設計目標としては、高速かつ安全にコードが実行できること、そしてハードウェアやプラットフォームに依存せず、ポータブルであることなどが掲げられています。
WebAssembly Core Specification

RubyとWebAssemblyの出会ったことで生まれたのがruby.wasmです。これにより、Rubyがブラウザ上で実行可能になりました。インタプリタのインストールは不要となり、Rubyのコードをより手軽に配布・実行できるようになったことで、先述したRubyの課題に対する一つの解決策となりました。

フルスタック開発におけるつらみ(私個人の経験と感想)

私個人のお話をさせてください。
普段は、主にRuby on Railsを使ったアプリケーション開発に従事していますが、案件によってはReact, Next.jsといったフロントエンド技術にふれることもあります。

そうした中で、個人的にReact周辺の知識のキャッチアップを行ってきたこともあり、「学習した内容のアウトプット」を目的として、以下のような構成のサンプルアプリを作ってみることにしました。

ところが、本来の目的であった「React学習のアウトプット」とは裏腹に、そこに至るまでの作業--つまりサーバーサイド側の実装に、想定以上の時間を費やしてしまいました。
サンプルアプリの作成は1ヶ月程度を見込んでいたのですが、結果的にそのうち約3週間をサーバーサイドの構築に費やすこととなり、肝心のReactの実装に充てられたのは1週間ほど……(そして間に合いませんでした…)。
こうした経験から、「サーバーサイドの実装はサクッと済ませたい」「可能であればモックAPIなどを使いたい」と感じていたところに、ruby.wasmとの出会いがありました。
「これは何か使えないだろうか」という思いつきから、今回の取り組みを試してみることにしました。

ruby.wasmモックAPIとしての活用

では、ruby.wasmをモックAPIとして使ってみる場合の構成を考えてみます。

……といってもそんな大げさな話ではありません。
特定の入力に対して期待する結果を返すRubyの処理を、ruby.wasmを使ってブラウザ上で実行する、というだけのものです。ただ、それをどうやってJSXファイルに埋め込むか、既存のReactプロジェクトのコンテキストに統合するか、といった点で少し苦労しました。

構成イメージとしては以下の通りです。
React側からruby.wasm上で動作するRubyのメソッドを呼び出し、その戻り値に応じてReactを動作させる、という流れです。

先ほどのサンプルアプリの構成イメージと比べると、サーバーサイドの実装がまるごと不要になったことがお分かりいただけるかと思います。
さらに、今回はReactプロジェクト単独での構成となったので、Docker環境も使用しないことにしました。

なお、本来はサーバー側と疎通している状態のものをモックAPI化するというのは、開発の流れとしては逆行する形となり違和感があるかもしれません。しかし今回はあくまで実験的な試みとして、その手順を以下にまとめています。

実際にやってみる

既存の構成

まずは、元々のサンプルアプリの構成を確認してみます。
例として、ログイン・ログアウトの処理を取り上げます。これらの処理は当初、ReactのContextを使って以下のような形にまとめていました。

// src/context/AuthContext.tsx
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
import axiosInstance from "@/utils/axios";

// [...省略]

export const AuthProvider = ({ children }: AuthProviderProps ) => {
  const [user, setUser] = useState<AuthorizedUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // [...省略]

  const createSession = async (data: LoginUserInput) => {
    const response = await axiosInstance.post<AuthorizedUser>("session", { user: data })
    return response
  }

  const deleteSession = async () => {
    const response = await axiosInstance.delete("session")
    return response
  }

  const login = async (data: LoginUserInput) => {
    try {
      const response = await createSession(data)
      setUser(response.data)
    } catch {
      throw new Error("ログインに失敗しました");
    }
  };

  const logout = async () => {
    const response = await deleteSession()
    if (response.status === 200) {
      setUser(null);
    } else {
      alert("再度お試しください")
    }
  };

  // [...省略]

ログインフォームは次のような流れで動作します:

  • ログインボタンがクリックされると、React側ではlogin関数と、その内部で呼び出されるcreateSession関数が実行されます。
  • createSession関数では、axiosInstanceを通じて Rails側にHTTPリクエストが送られます。
  • このaxiosInstanceは、共通のBaseURLやリクエストヘッダーを設定し、それを使い回すことのできるよう、素のaxiosをラップしたカスタム関数になっています。
  • Railsから認証に成功したレスポンスが返ってくると、そのユーザー情報をuserというstate変数にセットする、という仕組みです。

一方、Rails側では/sessionエンドポイントを用意し、送信されたメールアドレスとパスワードが DB上のレコードと一致すれば、ユーザー情報を返すだけのシンプルな構成としています(認証処理は Rails 8 で導入された認証ジェネレータを利用して簡易に実装しました)。

HTTPリクエストしている部分を関数呼び出しに書き換える

上記のaxiosを使った箇所を、以下のようにmockAPIRequestという関数による処理に書き換えます。

 const createSession = async (data: LoginUserInput) => {
    // const response = await axiosInstance.post<AuthorizedUser>("session", { user: data })
    // axios使用箇所を以下のように書き換える
    const response = await mockAPIRequest("/session", { user: data })
    return response
  }

  const deleteSession = async () => {
    // const response = await axiosInstance.delete("session")
    // axios使用箇所を以下のように書き換える
    const response = await mockAPIRequest("/delete_session")
    return response
  }

このmockAPIRequest関数の中でruby.wasmを活用しています。
React側の構造を大きく変更しないよう、axiosと同様の形で引数を受け取り、またRails側から返ってくるJSONの形式と同様のデータを返すように実装しました。

カスタム関数内部でRubyのコードを書く

次に、mockAPIRequest関数の中身について説明します。

まず、JSX構文内でruby.wasmを使えるようにする必要があります。これについてはruby.wasmのドキュメントにCheatSheetが用意されており、非常に参考になりました。

セットアップ

このガイドに従って、まずは以下のパッケージをインストールします。

npm install --save @ruby/3.4-wasm-wasi @ruby/wasm-wasi

さらに、私のローカル環境ではViteを使用していたため、追加で以下のセットアップも行いました。

npm install --save-dev vite-plugin-wasm

ruby.wasm を初期化する

以下は実際のコードの一部で、ruby.wasm を初期化している部分です。

// src/api/mockAPI.ts

import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.1/dist/browser/+esm";

let rubyVM: Awaited<ReturnType<typeof DefaultRubyVM>>["vm"] | null = null;
const wasmURL = "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@2.7.1/dist/ruby+stdlib.wasm";

// Rubyの実行環境を初期化
async function initializeRubyVM() {
  if (rubyVM) return rubyVM;

  const response = await fetch(wasmURL);
  const module = await WebAssembly.compileStreaming(response);
  const { vm } = await DefaultRubyVM(module);
  rubyVM = vm;
  return rubyVM;
}

// mockAPIRequestの実装
export async function mockAPIRequest(endpoint: string, params?: any): Promise<any> {
  const vm = await initializeRubyVM()

  const endpointJson = JSON.stringify(endpoint);
  const requestDataJson = JSON.stringify(params || {});

  // Ruby code is here!!
  const rubyCode = `
    [...]
  `;

  const result = await vm.eval(rubyCode);
  return { data: JSON.parse(result.toString())};
};
  • DefaultRubyVM@ruby/wasm-wasiパッケージから提供される、WebAssembly上で動作する Ruby仮想マシン(VM)の初期化関数です。
  • let rubyVM~で定義した変数は、初期化したRubyVMを保持し、使い回すために用意しています。
  • initializeRubyVM関数内では、.wasmファイルをストリームで読み込み、WASMモジュールとしてコンパイルし、それをRubyVMに割り当てる処理を行っています。

mockAPIRequest関数の中身

続いて、mockAPIRequest関数の具体的な実装について。

  • この関数は、endpoint(リクエスト先のパス)とparams(リクエストパラメータ)の2つの引数を受け取ります。
  • Rubyのコードは文字列としてrubyCode変数に格納します。
  • initializeRubyVMを呼び出してRubyVMを初期化した後、そのVMに対して.evalを実行し、rubyCodeの内容を処理します。
  • Ruby側の処理結果をJSON文字列として返し、それをJavaScript側でJSON.parseすることで Reactからも扱える形に変換しています。

Ruby 側のコード(抜粋)

rubyCodeの中身は以下の通りです。ログイン認証をモック化している部分を抜粋しました。

const rubyCode = `
    require 'json'
    require 'uri'

    $users = [
      { id: 1, name: "スネイプ", email_address: "snape@sample.com", password: "foobar0101" },
      { id: 2, name: "ハリー", email_address: "harry@sample.com", password: "foobar0101" },
      { id: 3, name: "ロン", email_address: "ron@sample.com", password: "foobar0101" }
    ]

    $logged_in_user ||=  { id: 1, name: "スネイプ" }


    endpoint = JSON.parse('${endpointJson}')
    request_data = JSON.parse('${requestDataJson}')

    if endpoint == "/session"
      authorized_user = $users.find do |user|
        user[:email_address] == request_data["user"]["email_address"] && user[:password] == request_data["user"]["password"]
      end

      if authorized_user
        $logged_in_user = { id: authorized_user[:id], name: authorized_user[:name] }
        JSON.generate($logged_in_user)
      else
        $logged_in_user = nil
        JSON.generate(nil)
      end
    elsif endpoint == "/delete_session"
      $logged_in_user = nil
      JSON.generate(nil)
    elsif endpoint == "/users"
      #...略
    elsif endpoint == "/books"
      #...略
    end
  `;
  • グローバル変数を使って、「データベース上のレコード」を模したデータを定義しています。
  • 引数で渡されたendpointに応じて条件分岐を行い、それに対応したレスポンスをRubyで返すようにしています。

実際にログインしてみる


この構成により、React 部分のリポジトリはRailsから完全に切り離され、axiosによるHTTPリクエスト処理も削除されました。
本来であれば、ログインボタンをクリックしても何も起こらないはずですが、ruby.wasmのおかげで Rubyの処理がブラウザ上で実行され、スネイプ先生でログインすることができました。

ログアウトも同様に可能です。

まとめ

以上、「ruby.wasmを簡単なモック API として使ってみる」というテーマで試行錯誤してきましたが、当初の課題であった「サーバーサイドはサクッと済ませて、Reactの開発に集中する」ための手段としては、正直なところ少し難しかったというのが率直な感想です。

まず、自分自身がruby.wasmについてよく理解しておらず、今回のような形にするまでに思った以上に時間がかかりました。また、既にaxiosを使ったHTTPリクエストが実装されている状態から、できるだけ元のコードを壊さずにruby.wasmを組み込む方法を考えるのにも苦労しました……。

結論として、「サーバーサイドは手軽に済ませて、フロントエンドの開発に集中したい」という目的であれば、素直にJSONPlaceholderのような既成のAPIモックサービスを使ったほうが 手間も少なくReact側もより実践的な構成で開発できると思います。

ただし今回、ruby.wasm を使って手を動かしながら試行錯誤した時間は、私にとって非常に貴重な経験となりました。自分で仮想のRuby実行環境をブラウザ上に構築し、その中で処理を組み立てるというのは、普段のフロントエンド開発ではなかなか得られない刺激がありました。

本記事の内容について、もし誤りや改善点がありましたら、お気軽にご指摘いただけると幸いです。

今回作成したコードは以下にアップしています。自分用にいろいろ遊んだ後の状態で、多少散らかっておりますがご容赦ください 🙇
https://github.com/JwtnbRd/ruby_wasm_as_api_mock

今回は以上となります。
ここまでお読みいただき、本当にありがとうございました!

Discussion