English

Vercel

Vercel のテンプレートは同じページ、アクション、ストリームを Vercel の関数として動かし、VercelLayer が受け取ったリクエストを axum の Router に渡します。

テンプレートからデプロイする

このコマンドを実行するには、Vercel のアカウント、Rust 1.99 以降、Node.js 22 以降が必要です。Node.js は Vercel のコマンドラインツール vercel を動かすために使い、npx が最初の実行時にダウンロードします。cargo generate は Vercel のテンプレートを my-app にコピーし、パッケージにその名前を付けます。

cargo install cargo-generate
cargo generate --git https://github.com/imaimai17468/hypersig templates/vercel --name my-app
cd my-app
cargo run
npx vercel login
npx vercel deploy --prod

cargo run は関数を http://localhost:3000 で起動します。Vercel の外では、vercel_runtime::run は 127.0.0.1 の VERCEL_DEV_PORT のポート(なければ 3000)で待ち受け、受けたリクエストをデプロイ先と同じ VercelLayer に渡します。vercel login はコマンドラインツールを Vercel にログインさせます。ツールのログインはブラウザのログインとは別なので、これを実行するまで vercel deploy は No existing credentials found で止まります。

最初の vercel deploy は、ディレクトリをどのアカウントのどのプロジェクトにするかを尋ね、プロジェクトを作って .vercel でディレクトリと結び付けます。.vercel はテンプレートの .gitignore で除外してあります。そのあとソースをアップロードし、Vercel が自分のサーバーで rustup の Rust を使ってビルドするので、バイナリはアップロードしません。--prod を付けるとプロジェクトの本番のアドレスに公開し、そのアドレスを表示します。名前が空いていれば https://my-app.vercel.app です。--prod がないとプレビューのデプロイになり、専用のアドレスに置かれます。Deployment Protection を有効にしたプロジェクトでは、プレビューは Vercel にログインしたメンバーにしか表示されません。プロジェクトに Git のリポジトリをつなぐと、ブランチへの push でプレビューが、main ブランチへの push で本番がデプロイされます。

テンプレートの中身

ファイル役割
api/app.rs下の関数です。+1 のボタンはブラウザで数を増やし、もう 1 つのボタンは関数で数を 2 倍にし、ストリームは関数の時刻を 1 秒ごとに送ります。
Cargo.tomlapi/app.rs からビルドするバイナリ app を宣言します。Vercel はこれを /api/app の関数にします。依存には axum の機能を付けた vercel_runtime があります。
vercel.jsonすべてのパスを /api/app に送ります。axum の Router は各リクエストを元のパスのまま受け取ります。また、Vercel が静的ファイルとして配信するディレクトリを空の public にします。理由は最後の節で説明します。
.vercelignoretarget をアップロードの対象から外します。
rust-toolchain.tomlRust 1.99 を指定します。Vercel のビルド環境にある Rust は hypersig が必要とするものより古いので、rustup がこのバージョンをインストールします。
api/app.rs
use std::time::Duration;

use axum::Router;
use futures::{Stream, stream};
use hypersig::html::{Stylesheet, button, div, h1, p, span};
use hypersig::{
    Action, DateFormat, DateStyle, IntlLocale, Page, Patches, Route, RouterExt, Signals,
    StreamAction, TimeStyle, Timestamp,
};
use serde::{Deserialize, Serialize};
use tower::ServiceBuilder;
use vercel_runtime::axum::VercelLayer;

#[derive(Signals, Serialize, Deserialize)]
struct Counter {
    count: i32,
    now: Timestamp,
}

hypersig::routes! {
    fn pages(Router<()>);
    const INDEX: Route = "/" => index;
}

const DOUBLE: Action<Counter, i32> = Action::post("/double");
const CLOCK: StreamAction<Counter> = StreamAction::new("/clock");

const TIME: DateFormat =
    DateFormat::date_time(IntlLocale::En, DateStyle::Medium, TimeStyle::Medium);

const STYLE: Stylesheet = Stylesheet::new(
    "
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; }
button { font-size: 1rem; padding: .4rem .9rem; }
",
);

async fn index((): (), (): ()) -> Page<Counter> {
    let count = Counter::count();
    let initial = Counter {
        count: 0,
        now: Timestamp::now(),
    };
    Page::new("hypersig on Vercel", initial).style(STYLE).child(
        div()
            .on_load(CLOCK.call().reconnect())
            .child(h1().text("Counter"))
            .child(
                p().child(button().on_click(count.set(count + 1)).text("+1"))
                    .child(span().bind_text(count.to_text()))
                    .child(
                        button()
                            .on_click(DOUBLE.call_with(count))
                            .text("Double on the function"),
                    ),
            )
            .child(p().bind_text(Counter::now().format_date(&TIME))),
    )
}

async fn double((): (), count: i32) -> Patches<Counter> {
    Counter::count().patch(count.saturating_mul(2)).into()
}

/// The function's time, once a second.
fn clock((): (), (): ()) -> impl Stream<Item = Patches<Counter>> {
    stream::unfold((), |()| async {
        tokio::time::sleep(Duration::from_secs(1)).await;
        Some((Counter::now().patch(Timestamp::now()).into(), ()))
    })
}

#[tokio::main]
async fn main() -> Result<(), vercel_runtime::Error> {
    let app = pages(Router::new())
        .hypersig_runtime()
        .action(DOUBLE, double)
        .stream(CLOCK, clock);
    // Outside Vercel, `run` listens on 127.0.0.1 at the port in
    // `VERCEL_DEV_PORT`, or 3000.
    vercel_runtime::run(ServiceBuilder::new().layer(VercelLayer::new()).service(app)).await
}

サーバーのプロジェクトを Vercel の関数に移行する

サーバーのプロジェクトを Vercel の関数に移行するには、main で Router を axum::serve ではなく vercel_runtime::run に渡します。関数はサーバーと同じく tokio の上で動くので、ページ、アクション、ストリームのコードは tokio::time::sleep も含めてそのまま使えます。

ファイル変えること
Cargo.tomlaxum の機能を付けた vercel_runtime と tower を追加します。path が api/app.rs で名前が app の [[bin]] も追加します。
src/main.rsapi/app.rs に移し、リスナーと axum::serve をテンプレートの最後の行に置き換えます。この行は Router を VercelLayer で包んで実行します。
vercel.json、.vercelignore、rust-toolchain.tomltemplates/vercel からコピーします。

Vercel で変わること

  • Vercel は rewrite より先に出力ディレクトリのファイルを配信し、出力ディレクトリを指定しないプロジェクトではプロジェクトのルートがそのディレクトリになります。その場合、Cargo.toml、Cargo.lock、src/ の Rust のソースを含めて api/ の外のファイルはすべて、そのパスで公開されます。試したファイルのうち公開されなかったのは .env だけでした。テンプレートの vercel.json は出力ディレクトリを public にし、ビルドのコマンドが空の .keep だけを入れてこのディレクトリを作るので、それ以外のパスはすべて関数に届きます。自分で vercel.json を書くときも、この 2 つのフィールドを残してください。
  • Vercel は関数の最大実行時間でリクエストを終えます。この時間にはストリームを開いている時間も含まれます。上限は既定で 300 秒で、Hobby プランでは 300 秒より延ばせません。Pro プランでは maxDuration で 800 秒まで延ばせます。サーバーはハンドラのストリームが終わると最後に hs-end のイベントを送ります。上限に達した Vercel はその前にレスポンスを閉じるので、reconnect() を付けた呼び出しはストリームが途中で切れたものとして扱い、1 秒後に開き直します。このテンプレートをデプロイして確かめると、時計のストリームはページを開いてから 301 秒で開き直り、時計は進み続けました。切れたことはほかの失敗と同じように扱われ、コンソールと hs-error のイベントに送られます。呼び出しの error のシグナルにも、ストリームが開き直るまで残ります。hs-error でメッセージを出すページでは、上限を迎えるたびにメッセージが出ます。
  • Vercel は関数のインスタンスを複数動かすことがあり、使われていないインスタンスは止めます。メモリに置いた値は次のリクエストで消えていることがあるので、リクエストをまたいで共有する状態はデータベースに置いてください。
  • VercelLayer はリクエストの本文をすべて読んでから Router に渡します。ファイルのアップロードを少しずつ読むコードでも、本文は一度にメモリに載ります。Vercel は 4.5 MB を超える本文を受け付けません。
  • hypersig_runtime() はランタイムを Brotli で圧縮し、Content-Encoding: br を付けて送ります。Vercel は途中でこれを圧縮し直すので、ブラウザが受け取るバイト列は関数が送ったものと変わります。ただし圧縮は 1 回だけで、展開すると同じランタイムになります。