じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

TopcoatでRustフルスタック開発

はじめに

Topcoatは、HTMLをサーバーで生成しながら、ブラウザ上の状態更新やサーバー処理もRustで記述するフルスタックフレームワークです。フロントエンド用のディレクトリやnpmを用意せず、1つのRustファイルから動きます。2026年7月に発表された0.5.0を検証しました。

サーバーサイドレンダリング、部分的な反応、セッションの転送層までは任せられます。認証、マイグレーション、運用はアプリケーション側に残ります。この記事は、その境界を1つずつ確認した記録です。

github.com

tokio.rs

SSRとルーティングは1ファイルで済む

Rust 1.95以上を用意し、Topcoat 0.5.0とTokioを追加します(検証は1.97.1)。

cargo add topcoat@0.5.0
cargo add tokio@1 --features rt-multi-thread,macros

#[page]がURLと関数を結び、view!がHTMLを生成し、discover()がマクロの登録内容を収集してRouterを組み立てます。ページが2つになるとDOCTYPEとheadが重複するので、共通部分は#[layout]へ移します。#[layout("/")]は/から始まるすべてのページを包み、(slot?)へ各ページの描画結果を差し込みます。

use topcoat::{
    Result,
    router::{Router, RouterBuilderDiscoverExt, layout, page},
    view::view,
};

#[tokio::main]
async fn main() -> topcoat::Result<()> {
    let router = Router::builder().discover().build();
    topcoat::start(router).await?;
    Ok(())
}

#[layout("/")]
async fn root_layout(slot: Result) -> Result {
    view! {
        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <title>"Hello Topcoat"</title>
                topcoat::dev::script()
            </head>
            <body>
                <nav>
                    <a href="/">"Home"</a>
                    " "
                    <a href="/about">"About"</a>
                </nav>
                (slot?)
            </body>
        </html>
    }
}

#[page("/")]
async fn home() -> Result {
    view! {
        <main>
            <h1>"Hello Topcoat"</h1>
            <p>"最初のページです。"</p>
        </main>
    }
}

#[page("/about")]
async fn about() -> Result {
    view! {
        <main>
            <h1>"About"</h1>
            <p>"Topcoat 0.5.0の検証用ページです。"</p>
        </main>
    }
}

cargo runを実行すると、標準では127.0.0.1:3000でサーバーが起動します。/と/aboutはどちらも同じナビゲーションを持ち、その後ろへ各ページのmain要素が入りました。ページを足す時に書くのは#[page]の付いた関数だけです。discover()が#[layout]と#[page]をリンク時に収集するため、Router::builder()へハンドラを1件ずつ登録する必要はありません。

topcoat::dev::script()は通常のcargo runではHTMLへ出力されず、開発CLIから起動した時にリロード用のスクリプトとして挿入されます。日常的な開発ではこちらを使います。ビルド、アセットのバンドル、サーバーの再起動、ブラウザのリロードをまとめて扱えます。

cargo install topcoat-cli --version 0.5.0
cargo topcoat dev

github.com

ページが増えて1ファイルを見通しにくくなった時点でモジュールへ分けます。モジュール構造からURLを導くmodule_router!もありますが、数ページの段階では明示的なパスのほうが対応関係を追いやすいです。

このRouterへCookie、セッション、DB、アセットを加えると、次の形になります。discover()が集める対象も、#[page]と#[layout]だけでなく#[route]、#[procedure]、#[shard]へ広がります。

pub fn router(db: Db, assets: Option<AssetBundle>) -> Router {
    let builder = Router::builder()
        .cookies()
        .sessions(SessionConfig::default())
        .app_context(db);

    let builder = match assets {
        Some(bundle) => builder.assets(bundle),
        None => builder,
    };

    builder.discover().build()
}

#[route]はPOSTなどのHTTP処理、#[component]は再利用する表示単位です。フロントエンドとバックエンドを別プロジェクトに分けず、同じRustのモジュール内で画面、認可、問い合わせを近くに置けます。

型が合わない入力はハンドラの手前で400になる

Topcoatのハンドラでは、パスパラメータとクエリパラメータをCxから読み、JSONなどのリクエストボディをエクストラクタで受け取ります。path_param、query_params、content::Json、routeはいずれもtopcoat::routerにあります。

パラメータ名はタプル構造体のArticleIdをスネークケースにしたarticle_idです。パス内の{article_id}と名前を合わせます。path_paramは同じリクエスト内で解析結果を保持します。

#[path_param(error = bad_request)]
struct ArticleId(u64);

#[page("/articles/{article_id}")]
async fn article(cx: &Cx) -> Result {
    let article_id = path_param::<ArticleId>(cx)?;
    view! {
        <main>
            <h1>"Article " (article_id)</h1>
        </main>
    }
}

クエリも同じ形です。#[query_params]を付けた構造体をquery_params::<SearchQuery>(cx)?で受け取り、Optionのフィールドはunwrap_orで既定値を置きます。検証用のページは、受け取ったqとpageをそのまま表示します。

#[query_params(error = bad_request)]
struct SearchQuery {
    q: Option<String>,
    page: Option<u32>,
}

APIルートはリクエストボディをJson<Message>で受け取り、同じ型を返せます。Serdeのderiveを有効にしておきます。

#[derive(Deserialize, Serialize)]
struct Message {
    text: String,
}

#[route(POST "/api/echo")]
async fn echo(Json(input): Json<Message>) -> Result<Json<Message>> {
    Ok(Json(input))
}

3種類を実際に送ると、パスとクエリのページはtext/htmlを、APIルートはapplication/jsonを返しました。不正な値はいずれも、ハンドラ本体で値を使う前に400を返しました。

GET  /articles/42              -> 200 OK, <h1>Article 42</h1>
GET  /search?q=rust&page=2     -> 200 OK, keyword: rust, page: 2
POST /api/echo {"text":"hello"} -> 200 OK, {"text":"hello"}

GET  /articles/not-a-number -> 400 Bad Request
GET  /search?page=abc       -> 400 Bad Request
POST /api/echo {"wrong":true} -> 400 Bad Request

クエリの値をview!へ埋め込むとHTMLエスケープされます。q=<script>alert(1)</script>を送った検証では、レスポンス内は&lt;script&gt;alert(1)&lt;/script&gt;になりました。

ハンドラへ渡せるリクエストボディは1つです。ボディはストリームであり、複数回消費できないためです。パスとクエリはCxから読み、ボディは1つのエクストラクタから受ける、と分けると境界が明確になります。

docs.rs

リアクティビティには3つの実行場所がある

HTMLはサーバーで生成し、リアクティブな命令をメタデータとして付加します。公式記事は、この考え方をHTMXに近いものとして説明しています。LeptosやDioxusのようにアプリケーション全体をWebAssemblyへコンパイルするのではなく、ブラウザで必要になる型検査済みRust式だけをマクロがJavaScriptへ変換します。

つまり、Rust風に見える処理がすべて同じ場所で動くわけではありません。検証した仕組みは3段に分かれました。

仕組み 実行場所 適した処理 サーバー往復
signal ブラウザ 開閉、選択、入力中の状態 なし
#[procedure] サーバー 検証、認可、DB更新 あり
#[shard] サーバー 検索結果などの部分SSR あり

詳細の表示と非表示はsignalだけで完結します。イベント式はブラウザで実行されるため、ボタンを押すたびにHTTPリクエストは発生しません。

#[component]
async fn detail_card(answer: &str) -> Result {
    view! {
        signal revealed = false;
        <button @click=$(|_event: Event| revealed.toggle())>
            $(if revealed.get() { "隠す" } else { "表示する" })
        </button>
        <p :hidden=$(!revealed.get())>(answer)</p>
    }
}

DB更新は#[procedure]へ渡します。procedure内では通常のサーバー側Rustとして現在のユーザーを取得し、入力を検証し、対象データの所有者を確認してから保存できます。ブラウザへアクセストークンやDB接続情報を渡す必要はありません。

検索では#[shard]を使いました。入力値をsignalへ書くと、サーバーはshardを再実行し、返したHTMLの断片だけを置き換えます。SPAならJSON APIやレスポンス型、クライアント側のfetch、状態管理を個別に実装します。Topcoatでは、その定型コードが不要でした。

Topcoatは画面とサーバーの境界を消すのではなく、signal、procedure、shardの3種類へ限定します。実行場所の選択は必要ですが、境界ごとの定型コードは減らせます。公式記事もクライアント側のリアクティビティは開発初期で制約があると説明しており、ブラウザ内に大きな状態を持つアプリより、SSRを中心に必要な箇所だけ反応させる画面に向いています。

docs.rs

セッションはあるが、認証機能が完成するわけではない

Topcoatのセッションを有効にすると、既定のCookieTokenStoreは__Host-プレフィックス、Secure、HttpOnly、SameSite=Lax、Path=/を設定します。状態を変更するリクエストでは、セッション層がSec-Fetch-SiteやOriginを検証し、HTTPテストでもクロスサイトのPOSTが403になりました。Cookie属性とオリジン検証をフレームワーク側の既定値として持つのは、安全な初期値として扱いやすい設計です。

docs.rs

docs.rs

一方、ユーザー登録やパスワード認証はアプリケーション側の責任です。パスワードはArgon2idでハッシュ化し、Topcoatが発行した32バイトのトークンはハッシュだけをDBへ保存しました。ログアウト時にはCookieと対応するセッションレコードを削除します。存在しないメールアドレスでもダミーのハッシュを検証し、応答時間からアカウントの存在を推測しにくくしました。

docs.rs

メール確認、パスワード再設定、ログイン試行の制限は別に実装する必要があります。Topcoat 0.5のセッションは、認証機能一式を提供するものではありません。

Toastyを組み合わせてもマイグレーションは消えない

TopcoatはORMを内蔵せず、DBが必要な時は同じtokio-rs組織のToastyを案内しています。公式サンプルに合わせてToasty 0.7とSQLiteを使い、5つのモデル、ユニーク制約、インデックス、トランザクションを検証しました。

github.com

初回起動、データ保存、画面表示は成功しました。失敗したのは、同じDBを使った2回目の起動です。

table "users" already exists in CREATE TABLE "users"

原因はTopcoatのRouterではなく、空のDB用のpush_schemaを既存のDBにも適用していたことでした。ローカル検証用のコードでは、SQLiteのファイルが存在しないか空の時だけ初期スキーマを作るように変更しました。次のコードはモデル名を簡略化した抜粋です。

pub async fn connect(url: &str) -> toasty::Result<Db> {
    let should_initialize = should_initialize(url);
    let db = Db::builder()
        .models(toasty::models!(User, AuthSession, Record))
        .connect(url)
        .await?;

    if should_initialize {
        db.push_schema().await?;
    }

    Ok(db)
}

この修正はマイグレーションではありません。既存のスキーマとモデルが異なる状況を解決できず、複数のインスタンスが同時に初期化する状況も対象外です。本番ではバージョン付きのマイグレーションを生成し、デプロイとは分離した1回限りのジョブとして適用し、バックアップからのリストアも検証する必要があります。

Routerを直接テストできる

TopcoatのRouterは、実際のTCPポートを開かずにRequestを渡してResponseを検証できます。登録、Cookie属性、認証ガード、ユーザー間のデータ分離、クロスサイトのPOST拒否、ログアウト後のセッション失効、再ログインを1本のHTTPフローとして確認しました。

let response = router
    .handle(
        Request::builder()
            .method("GET")
            .uri("/app")
            .body(Body::empty())?,
    )
    .await;

assert!(response.status().is_redirection());

最終確認ではRustのテスト9件、6つのテストスイートが成功し、Clippyも警告なしでした。ただし、マクロが生成するクライアントランタイムはRouterのテストでは見えません。別に実ブラウザのE2Eを実行し、signalによる表示切り替え、procedureによる更新、shardでの検索まで通しています。

OpenTelemetryは自動では入らない

Topcoat 0.5.0がリクエストごとのスパンを自動生成するわけではありません。#[layer("/")]でリクエストを囲み、tracing-opentelemetryからOpenTelemetry SDKへ渡しました。標準出力のエクスポータを使った検証では、GET /aboutに対してhttp.requestというServerスパンが1件生成されました。Resourceのservice.nameと、HTTPのメソッド、パス、ステータスコードを確認しています。

クエリ文字列、Cookie、利用者情報は記録していません。tracing-opentelemetryが既定で加えるメタデータも無効にしました。対象はソース位置、スレッド、target、busy time、idle timeです。リクエストの属性を3項目へ限定し、値と型、個数をメモリ内のエクスポータを使ったテストで固定しています。

ただし、Topcoatのレイヤー内でnext.runがErrを返した時、最終的なHTTPエラーレスポンスへの変換はレイヤーの外側で行われます。この最小実装は成功したレスポンスのステータスを記録できますが、ハンドラのエラーから作られた4xxと5xxを正確に記録するには、さらに外側のHTTP境界へ計装を置く必要があります。

docs.rs

Topcoatをどこで選ぶか

検証結果をフレームワークとアプリケーションの責任に分けると、次のようになります。

領域 Topcoat 0.5が担うもの アプリケーションで必要なもの
HTML SSR、レイアウト、コンポーネント、アセット配信 情報設計、アクセシビリティ、CSP
リアクティビティ signal、procedure、shard 実行場所の選択、入力検証、認可
セッション Cookie属性の既定値、トークン発行、オリジン検証 セッションレコード、期限、失効方針
認証 セッションを組み込む境界 パスワード、再設定、メール確認、レート制限
DB アプリケーションコンテキストからの非同期アクセス ORM選定、マイグレーション、バックアップ、リストア
運用 開発サーバーとアセットバンドル ヘルスチェック、可観測性、デプロイ、ロールバック

他の言語でいえば、Goのchi + templ + HTMX、PythonのDjango + HTMXに近い構成です。Topcoatはこれらにブラウザ上のsignalを加え、1つのフレームワークで接続します。TypeScriptならNext.js App Routerが比較対象です。asyncなコンポーネントはServer Component、#[procedure]はServer Actionに近い役割を持ちます。違いは、ReactのRSCペイロードやClient Componentのハイドレーションを使わず、必要なRust式だけをJavaScriptへ変換する点です。

htmx.org

https://nextjs.org/docs/app/getting-started/server-and-client-componentsnextjs.org

私は、Rustに習熟したチームがサーバーサイドレンダリング中心の業務画面や小規模サービスを作るなら、Topcoatを候補に入れます。asyncなコンポーネントからその場で認証とDBへ到達しつつ、必要な箇所だけをsignal、procedure、shardで反応させられるからです。反対に、ブラウザ内で長時間動く編集環境、オフラインファースト、複雑なクライアント状態が中心なら、WebAssembly系のフレームワークやTypeScriptのSPAを比較します。JSON APIだけが必要なら、公式記事が説明する通り、より低い層のAxumを選ぶほうが責任範囲は明確です。

公式リポジトリはTopcoatを初期段階かつ実験的と位置づけ、破壊的変更を予告しています。ロードマップにはバリデーション、ローカライゼーション、認証、OpenAPI、ミドルウェア、バックグラウンドジョブ、ストリーミングSSRなどが並びます。ただし実装順、対象バージョン、期限はなく、2026年8月12日時点でGitHub Milestonesも作成されていません。ロードマップの認証がユーザー登録、パスワード再設定、MFAまで含むかも書かれていないため、現時点のアプリケーション責任を将来の機能で埋めたことにはできません。

github.com

github.com

採用するなら0.5.0を固定し、認証、認可、マイグレーションをTopcoat固有のAPIから分離して、バージョン更新ごとにHTTPフローとブラウザE2Eを通します。

ここまで書いて、立ち止まる。分離せよと書いた私の検証コードは、push_schemaをそのまま呼んで2回目の起動で落ちました。分離を勧める側が、最初にそれを踏んでいます。境界を説明できることを採用条件にするなら、その条件は最初に自分へ返ってきます。

検証環境

環境はmacOS 26.5.2のarm64、Rust 1.97.1、Topcoat 0.5.0、Toasty 0.7.0で揃えました。検証コードのCargo.tomlではrust-version = "1.95"としていますが、Rust 1.95での動作は確認していません。整形、静的解析、テスト、永続DBを使った起動は次のコマンドで確認しました。

cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all --all-targets
DATABASE_URL=sqlite:verification.db cargo topcoat dev

おわりに

TopcoatはHTMLの生成、サーバーとクライアント間の反応、セッションの転送層をRustへ集約します。一方、パスワードのライフサイクル、認可ポリシー、スキーマのマイグレーション、バックアップ、監視はアプリケーション側に残ります。SQLiteの再起動エラーを解消しても、バージョン付きのマイグレーションが不要になるわけではありません。

採用の条件は、procedureとshardの境界をチームで説明できることと、破壊的変更を前提に再検証を続けられることです。ロードマップは広いものの、リリース計画はまだ固定されていません。

このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。