Full AI

Kagero: Ruby WayでInertiaの体験を作る ... users

はじめに

sinatra-kagero(Kagero)は、React・Vue・Svelteを書かずに、SinatraのrouteとRubyのPageクラスだけでSPA的な画面遷移を作るgemです。 route、props、入力の検証、HTMLはRubyで書きます。 ブラウザ側のfetch、履歴、DOMの差し替えは、gemに入っている小さなJS(以下、runtime)が受け持ちます。

Cloudflare Workersで動かしています。

サンプルは、Sinatraをhomura(Opal)でCloudflare Workersに載せ、D1をデータベースに使っています。

Kagero D1 TodoのPC幅の画面。Rubyで書いたPageがCloudflare Workers上でSPA的に動くTODOアプリ

Inertiaのpage objectを、Rubyで描く

Inertiaは、サーバーがJSONのAPIではなく「次のページ」を返す仕組みです。 ページは、どのcomponentをどのpropsで描くかを書いたJSONで、page objectと呼ばれます。 普通のInertiaでは、ブラウザ側のReactやVueのcomponentがこのJSONを受けて描きます。

Kageroはpage objectの形をそのまま使い、componentの描画だけをサーバーのRubyに戻します。 RubyのPageクラスがHTMLまで作り、そのHTMLをprops.kagero.htmlに入れて返します。 サンプルの/にInertiaの要求を送ると、次のJSONが返ります(HTMLとtodosの一部を省略)。

{
  "component": "Todos/Index",
  "props": {
    "flash": {},
    "todos": [
      {
        "id": 10,
        "title": "これが日本語対応!",
        "done": true,
        "created_at": 1782437916
      },
      "…"
    ],
    "stats": {
      "total": 3,
      "open": 1,
      "done": 2
    },
    "errors": {},
    "kagero": {
      "component": "Todos/Index",
      "html": "<style>…</style><section class=\"shell\"><header class=\"hero\">…",
      "title": "Kagero D1 Todo"
    }
  },
  "url": "/",
  "version": "kagero-1"
}

runtimeは、このJSONを受けて<main>の中身を差し替え、URLとタブのタイトルを書き換えるだけです。

1回のフォーム送信で起きること

TODOを1件追加するときの流れです。

ブラウザ Kagero runtime(gem に同梱の JS) サーバー(Workers) Sinatra + sinatra-kagero + D1 ① / を普通に開く GET / Index を描いた HTML に page object と runtime を埋め込む HTML + XSRF-TOKEN の cookie ② フォームを送信 runtime が送信を横取り fetch POST /todos X-Inertia + X-XSRF-TOKEN CSRF: cookie とヘッダーを照合  → 違えば 403、D1 は変えない Command で入力を検証 D1 に insert redirect_page("/", Index, ...) Index#call で HTML を描く url を / にした page object (JSON)を返す page object(JSON) component・props・kagero.html・url ③ runtime が適用 page object の形を確かめる <main> の HTML を差し替え タブのタイトルを更新 pushState で URL を / に (失敗なら画面上部に表示)

最初の表示だけは普通のHTMLです。 サーバーはPageを描いたHTMLに、page objectとruntimeを埋め込んで返します。 同時に、偽のフォーム送信を防ぐトークン(XSRF-TOKEN)をcookieに入れます。

2回目からは、runtimeがクリックと送信を横取りしてJSONでやり取りします。 送信が通ったら、サーバーは一覧ページを描き直して、URLを/にしたpage objectを返します。 POSTのあとに/へリダイレクトするのと同じ結果を、1往復で得られます。

アプリ側のコード

route

register Sinatra::KageroしたSinatraアプリです。 pageでPageを返し、送信後はredirect_pageで「次のページ」を返します。

class App < Sinatra::Base
  register Sinatra::Kagero
  # 設定とヘルパー(db・todos・todo_stats・index_page など)は省略

  get "/" do
    page(
      Pages::Todos::Index,
      todos: todos,
      stats: todo_stats,
      errors: page_errors || {},
      flash: flash_payload
    )
  end

  post "/todos" do
    command = Commands::CreateTodo.new(params)
    if command.valid?
      db[:todos].insert(title: command.title, done: 0, created_at: Time.now.to_i)
      if page_request?
        index_page(flash: {notice: "Todo added"})
      else
        set_flash(notice: "Todo added")
        redirect to("/"), 303
      end
    elsif page_request?
      index_page(errors: command.errors)
    else
      page_errors(command.errors)
      redirect to("/"), 303
    end
  end

  # GET /done、完了の切り替え、削除の route は省略
end

page_request?は、要求にX-Inertia: trueヘッダーが付いているか(runtimeからの要求か)を見ます。 index_pageはredirect_page("/", Pages::Todos::Index, ...)を呼ぶヘルパーです。 runtimeからの要求なら一覧のpage objectをURL/で返し、それ以外でCSRFの検査を通った要求なら303で/へリダイレクトします。

Page

画面はKagero::Pageで書きます。 Kagero::PageはPhlexのPhlex::HTMLを継承していて、h1 { "..." }のようにRubyのメソッド呼び出しでHTMLを組み立てます。

module Pages
  module Todos
    class Index < Kagero::Page
      title "Kagero D1 Todo"

      props do
        prop :todos, Array
        prop :stats, Hash, required: false, default: -> { {} }
        prop :errors, Hash, required: false, default: -> { {} }
        prop :flash, Hash, required: false, default: -> { {} }
      end

      def view_template
        style { raw(safe(css)) }

        section(class: "shell") do
          header(class: "hero") do
            # 見出しと説明文(省略)
          end

          kagero_link(href: "/done") { "完了済み" }
          render_flash
          render_stats
          render_form
          render_list
        end
      end

      private

      # render_flash・render_stats は省略

      def render_form
        section(class: "panel", "aria-label": "Create TODO") do
          kagero_form(action: "/todos", method: "post", class: "new-form", "data-kagero-preserve-scroll": "true") do
            div(class: "field") do
              label(for: "title") { "Title" }
              input(
                id: "title",
                type: "text",
                name: "title",
                placeholder: "次にやること",
                "aria-invalid": field_error?(:title).to_s,
                "aria-describedby": field_error?(:title) ? "title-error" : nil
              )
              if field_error?(:title)
                p(id: "title-error", class: "error", role: "alert") { error_for(:title) }
              end
            end

            button(type: "submit", class: "button-primary") { "追加" }
          end
        end
      end

      # render_list・error_for・CSS などは省略
    end
  end
end

propsは、Pageが受け取る値の宣言です。 宣言にない値は捨て、必須の値が無いときや型が違うときは例外にします。 kagero_formとkagero_linkは、普通の<form>と<a>にdata-kagero属性を付けるだけのヘルパーで、runtimeはこの属性を目印に送信とクリックを横取りします。

Command

入力の検証もRubyです。

module Commands
  class CreateTodo < Kagero::Command
    attribute :title, String, default: ""

    validates_presence_of :title, message: "title_required"
    validates_length_of :title, maximum: 80, message: "title_too_long"

    def title
      @title.to_s.strip
    end
  end
end

検証に落ちると、routeはerrorsを入れた一覧のpage objectを返し、Pageが入力欄の下にエラーを描きます。

Kagero D1 TodoでTODOを追加し、空欄のエラーを出し、完了に切り替え、削除し、完了済みのページへ移って戻る操作のGIF

gemの中身

page helper

pageと、runtimeからの要求を受けたredirect_pageは、どちらもrender_kagero_pageに流れます。

def render_kagero_page(page_class, props, url: request.fullpath)
  component = page_class.kagero_component_name
  page_instance = page_class.new(**props)
  page_props = page_instance.kagero_props
  body_html = page_instance.call

  response_props = page_props.merge(
    kagero: Sinatra::Inertia.always(
      {
        component: component,
        html: body_html,
        title: page_instance.kagero_title
      }
    )
  )

  if inertia_request?
    render_kagero_json(component, response_props, url)
  else
    render_kagero_shell(component, response_props, url)
  end
end

page_instance.callがPhlexのHTMLを作ります。 Inertiaの通信の部分(page objectの組み立て、asset versionの照合、部分更新)は、下の層のsinatra-inertiaが受け持ちます。 kageroをalwaysで包む理由は、次の部分更新の節で説明します。

runtime

runtimeの中心はvisitです。 同じoriginの要求だけを送り、POSTなどにはXSRF-TOKENcookieの値をX-XSRF-TOKENヘッダーに入れます。 返ってきたJSONがKageroのpage objectの形をしているかを確かめてから、画面と履歴を書き換えます。

async function visit(url, options = {}) {
  const method = (options.method || "GET").toUpperCase();
  const targetUrl = new URL(url, location.href);
  if (targetUrl.origin !== location.origin) {
    if (method === "GET") location.href = targetUrl.href;
    else showFailure();
    return;
  }
  startLoading();
  const headers = new Headers(options.headers || {});
  headers.set("X-Inertia", "true");
  headers.set("X-Inertia-Version", currentPage ? currentPage.version : "");
  // X-Requested-With と Accept の設定(省略)

  if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
    const cookie = document.cookie.split("; ").find((entry) => entry.startsWith("XSRF-TOKEN="));
    if (cookie) headers.set("X-XSRF-TOKEN", decodeURIComponent(cookie.slice("XSRF-TOKEN=".length)));
  }

  try {
    const response = await fetch(url, {
      method,
      body: options.body,
      headers,
      credentials: "same-origin",
      mode: "same-origin",
      redirect: "follow"
    });

    // 応答のorigin、409(asset versionの不一致)、失敗応答、page objectの形の検査、
    // 部分応答のpropsの重ね方(省略)

    rememberScroll();
    document.getElementById("kagero-visit-error")?.remove();
    applyPage(page, {
      replace: options.replace === true,
      preserveScroll: options.preserveScroll === true
    });
  } catch (error) {
    showFailure();
  } finally {
    finishLoading();
  }
}

mode: "same-origin"にしているので、途中で別のoriginへリダイレクトされるとfetch自体が失敗し、トークンは外へ出ません。 失敗したときは画面の上に短いメッセージを出し、入力中の文字はそのまま残します。

runtimeの仕事はほかに、クリックと送信の横取り、戻る・進むでの画面の復元、通信中に画面上部へ出す細い線があります。 アプリを書く側が触るJSはありません。

部分更新

一覧の上の「Partial reload」ボタンは、統計(stats)を指定して部分更新を頼みます。

def render_stats
  section(class: "stats", "data-testid": "stats") do
    stat("Total", stat_value(:total))
    stat("Open", stat_value(:open))
    stat("Done", stat_value(:done))
    kagero_partial_button(only: :stats, class: "stat refresh", "aria-label": "Refresh stats") do
      span { "Partial reload" }
      strong { "stats" }
    end
  end
end

runtimeは、Inertiaの部分更新のヘッダー(X-Inertia-Partial-Component: Todos/IndexとX-Inertia-Partial-Data: stats)を付けて同じURLを取り直します。 サーバーのrouteはいつもどおり一覧と統計の両方を取り、ページ全体のHTMLを描きます。 そのあとsinatra-inertiaが、応答に入れるpropsをstatsに絞ります。

ここに落とし穴がありました。 1.0.2までは、画面のHTMLを入れたkageroも絞られて消え、runtimeは画面を書き換えませんでした。 サーバーは新しい件数を返しているのに、表示は変わらないままでした。 1.1.0ではkageroをInertiaのalways(部分更新でも必ず返すprop)にしたので、応答はstatsとkageroになります。

つまりKageroの部分更新で減るのは、返すpropsのJSONだけです。 データベースへの問い合わせと描画はページ全体の分だけ走り、画面もページ全体が新しくなります。 返ってこなかったprops(この例ではtodosなど)は、runtimeが前の値を残します。

CSRFの守り

1.1.0から、サンプルはsinatra-inertiaのCSRFの守りを有効にしています。 サーバーはXSRF-TOKENcookieを配り、POSTなどでは同じ値のX-XSRF-TOKENヘッダーを求めます。 一致しない要求は、routeに届く前に403で拒否し、D1には書き込みません。

ヘッダーを付けるのはruntimeなので、このサンプルのフォーム送信にはruntimeが要ります。 JavaScriptが動かない状態の普通のフォーム送信は403になり、routeの303の分岐には届きません。

Turbo・Inertiaとの違い

名前 画面を描く場所 画面の更新
Turbo サーバーのHTML ドキュメントやframeのHTMLを差し替える
Inertia ブラウザのcomponent page object(component+props)を受けてcomponentで描く
Kagero サーバーのRuby(Phlex) page objectを受け、入っているHTMLで差し替える

Hotwireに寄せるならHTML断片の差し替えになります。 Kageroはpage objectという形を残したまま、componentをRubyに置いています。 asset versionの照合や部分更新のように、Inertiaの通信の決まりをそのまま使えます。

おわりに

Kageroは、Inertiaのpage objectの中身をRubyで描いたHTMLにしたgemです。 アプリはroute、Page、CommandをRubyで書き、ブラウザ側は送信・履歴・差し替え・CSRFヘッダーを受け持つruntimeだけで動きます。 1.1.0で部分更新とCSRFの守りが、公開サンプルでも動くようになりました。

Enjoy, Ruby-way Inertia!