kickflow Tech Blog

株式会社kickflowのプロダクト開発本部によるブログ

Railsサーバーとブラウザで同一のJavaScriptコードを使う:mini_racer

聖杯とされる物の一つ「サクロ・カティーノ」(本文とは関係ありません)。Sylvain Billet, CC BY-SA 4.0 https://creativecommons.org/licenses/by-sa/4.0, via Wikimedia Commons

kickflowプロダクト開発本部の小本です。

Write once, run anywhere(一度書けばどこでも動く)、それはプログラマーが太古より求め続ける聖杯です。

今回は Ruby gem「mini_racer」を使い、サーバーサイドとフロントエンドで数式評価エンジンを統一した事例をご紹介します。

なぜサーバーサイドでJavaScriptを実行するのか?

kickflowは業務プロセスの自動化を支援する汎用ワークフロー製品です。

その中でも「自動計算型フィールド」は、多くのお客様にご利用いただいている機能です。他フィールドへの入力をトリガーとして、ブラウザ上でリアルタイムにExcel風の数式を評価し、値を計算します。

しかし、フロントエンドのJavaScriptコードはブラウザのデベロッパーツールなどでユーザーが容易に改変可能であり、「経費申請の合計金額を不正に変更して承認フローをバイパスする」といった不正行為を防ぐため、サーバーサイドでも計算を再実行して結果の正当性を検証する必要があります。また、kickflowではAPIを使用してブラウザを経由せずに申請をする機能があり、サーバーサイド単独で数式を評価できる必要があります。

kickflow創業時は、RubyとJavaScriptでそれぞれ別個に数式評価エンジンを実装していました。しかし、この方法には以下のような課題がありました:

  • 実装の重複: 同じロジックを2つの言語で管理する必要があり、メンテナンスコストが高い
  • 挙動の差異: 数値の桁数や型変換においてJavaScriptとRubyでは仕様が異なるため、同じ動作を保証するのが困難

mini_racerとは

github.com

mini_racerはV8 JavaScriptエンジンをRubyに組み込む拡張ライブラリです。Railsのアセットパイプラインでも使われており、実績のあるライブラリです。

mini_racerの特徴:

  • V8エンジンを利用するためブラウザやNode.jsと同じJavaScriptの挙動を実現
  • シンプルなAPIで、JavaScriptコードの実行や関数の呼び出しが可能
  • タイムアウトやメモリ制限などのセキュリティ機能を備える

なお、重要な点としてmini_racerはV8エンジンを提供しますがNode.jsの機能を含んでいるわけではありません。そのためnpmパッケージを直接利用することはできません。 もし、外部のJSライブラリを使いたい場合は、事前にバンドルする必要があります。

mini_racer以外の選択肢

mini_racer以外の選択肢については、こちらの記事で検討しています。

tech.kickflow.co.jp

kickflowのユースケースでは、JavaScriptコードをそれなりに高速に実行する必要があったため、JavaScriptエンジンをRubyに組み込む拡張ライブラリが必要でした。

JavaScriptエンジンを組み込む拡張ライブラリとしてはquickjs.rbduktape.rbなどもありますが、これらはmini_racerに比べて利用実績が少なかったり、タイムアウトなどの機能が不足していたため採用を見送りました。

実装方法

1. JavaScriptコードのバンドリング

まず、数式評価エンジンをTypeScriptで実装します。Rubyから呼び出す関数をexportします。

export function evaluate(formula: string, scope: Record<string, any>): any {
  // ここに数式の評価ロジックを実装
  // dayjsやdecimal.jsなどのライブラリを活用してExcel風の数式を評価する
  // 実際には数千行のコードになる
  return '評価結果'
}

package.jsonに依存ライブラリを記述します。なお依存ライブラリはdependenciesではなくdevDependenciesに記載します。

{
  "name": "@kickflow/formula",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "license": "Private",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsup",
    "test": "vitest",
    "lint": "eslint ."
  },
  "files": [
    "dist/index.js",
    "dist/index.d.ts"
  ],
  "dependencies": {},
  "devDependencies": {
    "@eslint/js": "9.31.0",
    "dayjs": "1.11.13",
    "decimal.js": "10.6.0",
    "eslint": "9.31.0",
    "eslint-config-prettier": "10.1.8",
    "eslint-plugin-prettier": "5.5.3",
    "globals": "16.3.0",
    "jsonpath-plus": "10.3.0",
    "mockdate": "^3.0.5",
    "prettier": "3.6.2",
    "tsup": "8.5.0",
    "typescript": "5.8.3",
    "typescript-eslint": "8.38.0",
    "vitest": "3.2.4"
  }
}

tsupを使って、実装したコードと依存ライブラリを単一のJavaScriptファイルにバンドルします。

tsup.egoist.dev

tsupはesbuildベースのツールでBundle your TypeScript library with no configを謳っているとおり、以下のような最小限の設定で動作します。

import { defineConfig } from 'tsup'

export default defineConfig({
  clean: true,
  dts: true,
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],
  outDir: 'dist',
  platform: 'browser',
})

ここではformat: ['cjs', 'esm']により、mini_racer用のCommonJS形式と、フロントエンド(Nuxt.js)用のESM形式の両方を出力します。platform: 'browser'を指定することで、Node.js固有の機能への参照を避けています。

2. Ruby側でのmini_racer実装

mini_racerを使ってJavaScriptコードを実行するラッパークラスを作成します:

Gemfileにmini_racerを追加します。JavaScriptライブラリ周りは伝統的にアップグレード時の問題が起きやすいため、mini_racerが依存するlibv8-nodeと共にバージョンを指定しています。

gem "libv8-node", "24.1.0.0"
gem "mini_racer", "0.19.0"

mini_racerが提供するインターフェースは以下のようなシンプルなものです:

context = MiniRacer::Context.new
context.eval("function hello(name) { return `Hello, ${name}!` }")
context.call("hello", "George")
# "Hello, George!"

JavaScriptコードの読み込みや、タイムアウトの設定をするため、以下のようなラッパークラスを定義しています。

# frozen_string_literal: true

# mini_racerを使って、RailsサーバーでJavaScriptのコードを実行するクラス
class Formula
  # 十分余裕を持たせたタイムアウト時間
  # evaluateやvalidateは基本的に数十ミリ秒程度で終わるはずで、
  # バグで無限ループに陥った場合の保険として設定する。
  TIMEOUT = 10_000

  # 十分余裕を持たせた最大メモリサイズ
  # evaluateやvalidateは純粋関数なので@miniracer_contextのメモリ使用量を増やさないはず。
  # バグでメモリ使用量が増える場合の保険として設定する。
  MAX_MEMORY = 50 * 1024 * 1024

  def initialize
    @miniracer_context = MiniRacer::Context.new(
      timeout: TIMEOUT,
      max_memory: MAX_MEMORY
    ).tap do |context|
      # .eval()メソッドを使っているが、固定のコードを渡しており安全である
      context.eval("var module = {}") # module.exports = { ... } を無視するためのダミー

      context.load(Rails.root.join("path/to/dist/index.cjs"))
    end
  end

  def evaluate(formula, scope)
    @miniracer_context.call("evaluate", formula, scope)
  end

  delegate :dispose, to: :@miniracer_context
end

なお、ここではセキュリティの観点から、以下の対策を実施しています:

  • タイムアウトの設定により、無限ループを防止
  • メモリ制限により、過度なメモリ使用を防止
  • ユーザー入力のJavaScriptコードを直接実行せず、数式の評価のみに限定

実装上の工夫

1. ライブラリの読み込み順序の問題

開発中、mini_racerより先にruby-vipsをrequireすると、mini_racerがクラッシュする問題に遭遇しました。以下のように起動時にrequireする順序を指定することで回避しています:

require_relative "boot"

require "rails/all"

# NOTE: mini_racerより先にruby-vipsをrequireすると、mini_racerがクラッシュする問題がある
require "mini_racer"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

2. バージョン管理の徹底

Gemfileでライブラリのバージョンを明示的に指定し、予期しないバージョンアップを防いでいます:

gem "ruby-vips", "2.2.4"
gem "libv8-node", "24.1.0.0"
gem "mini_racer", "0.19.0"

3. テストの実装

既存のRuby実装・TypeScript実装との互換性をテストしました。また、TypeScriptコード自体もVitestでテストを実施し、品質を担保しています。

4. 定期的な再起動

kickflowで使用しているHerokuではサーバーを1日1回再起動しています。これにより、万が一のメモリリークがあった場合でも、影響を最小限に抑えることができます。

導入の成果

mini_racerの導入により、以下の成果を得ることができました:

  1. コードの一元化: RubyとJavaScriptの二重実装が解消され、メンテナンスコストが大幅に削減
  2. 完全な互換性: フロントエンドとサーバーサイドで完全に同じ計算結果を保証
  3. 開発効率の向上: 新しい数式や関数の追加が容易になり、開発速度が向上
  4. 安定した運用: リリース後、mini_racer関連の不具合は発生していない

補足:セキュリティについて

1. 任意コードを実行する機能ではないこと

kickflowの自動計算型フィールドは「ユーザーが入力したJavaScript式を実行する機能」ではなく、「ユーザーが入力したExcel風の計算式をJavaScriptコードで評価する機能」です。本機能では任意JavaScriptを実行しないため、コードインジェクションのリスクはありません。

2. JavaScriptからRubyの機能にはアクセスできない

mini_racerではJavaScriptのコードはContext内で実行され、明示的にattachしなければJavaScriptからRubyコードを呼び出すことはできません。

context = MiniRacer::Context.new
puts context.eval("math.adder(20,22)") # エラー

context.attach("math.adder", proc{|a,b| a+b}) # コンテキストにRubyコードを呼び出す関数を追加
puts context.eval("math.adder(20,22)") # 42

そのため、悪意ある JavaScriptコードによってRailsサーバーが操作される恐れはありません。

3. mini_racerの実装不備による脆弱性

mini_racerはC言語で書かれた拡張ライブラリです。C言語には実装上の不備によってバッファオーバーフローなどの脆弱性が生じる問題があります。 ただし、それは"zlib"や"json"、"pg"といったライブラリも同様であり、mini_racer特有の問題ではありません。

なお、kickflowではGitHubのRenovateなどを使ってライブラリを定期的にアップデートし、脆弱性にも素早く対応できる体制をとっています。

まとめ

mini_racerを活用することで、Railsサーバー上でJavaScriptコードを安全に実行でき、フロントエンドとサーバーサイドで同一のロジックを共有することができました。

特に複雑な型変換や計算ロジックを含む機能においては、各言語で別々に実装するのに対して、mini_racerは有力な選択肢となります。


kickflowでは、このような技術的なチャレンジに一緒に取り組んでいただける仲間を募集しています。ご興味のある方は、ぜひ採用サイトをご覧ください。

careers.kickflow.co.jp