English

デプロイ

テンプレートから、そのままデプロイできるプロジェクトを作れます。コンテナのホスト向けのイメージと、Cloudflare Workers 向けの Worker の 2 つがあり、どちらも空のディレクトリから数行のコマンドで動くところまで進みます。

コンテナでデプロイする

このコマンドを実行するには、Rust 1.99 以降と Docker が必要です。cargo generate はサーバーのテンプレートを my-app にコピーし、パッケージにその名前を付けます。最後の 2 行でイメージをビルドし、起動します。

cargo install cargo-generate
cargo generate --git https://github.com/imaimai17468/hypersig templates/server --name my-app
cd my-app
docker build -t my-app .
docker run --rm -p 3000:3000 my-app

http://localhost:3000 を開いてください。プロジェクトの中身は クイックスタート のカウンターで、すべてのインターフェースの PORT のポートで待ち受けます。Dockerfile は 1 つ目のステージでバイナリをビルドし、2 つ目のステージで小さなイメージにコピーします。ブラウザのランタイムはバイナリに埋め込まれているので、イメージに入るのはバイナリだけです。Fly.io、Cloud Run、Render のように Dockerfile からビルドするホストにはこのままのプロジェクトを渡し、イメージを動かすホストにはここでビルドしたイメージをそのホストのレジストリに push して渡します。

既存のプロジェクトに Dockerfile を追加する

テンプレートの Dockerfile はパッケージの名前を書いていないので、バイナリが 1 つのパッケージならどのプロジェクトでもそのままビルドできます。Cargo.toml のあるディレクトリにダウンロードし、target をビルドの対象から外す .dockerignore も置いてください。

curl -O https://raw.githubusercontent.com/imaimai17468/hypersig/main/templates/server/Dockerfile
echo target >> .dockerignore

このリポジトリの例のように 127.0.0.1 で待ち受ける main には、コンテナの外から接続できません。すべてのインターフェースの PORT のポートで待ち受けるように変えてください。

// A container host names the port in `PORT` and connects from outside the
// container, so listen on every interface.
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_owned());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
axum::serve(listener, app).await

Dockerfile は cargo install --path . でビルドします。ワークスペースのルートにはパッケージがないので、そこでは失敗します。その場合は . をパッケージのディレクトリに変えてください。イメージの Rust は 1.99 なので、プロジェクトの rust-version がそれより新しいときは FROM の行のバージョンも上げてください。

ホストに必要なこと

  • ストリームはパッチを送るあいだリクエストを開いたままにします。Cloud Run のようにリクエストを一定の時間で終えるホストでは、その時間でストリームが切れるので、上限を延ばしてください。reconnect() を付けた呼び出しはストリームをつなぎ直します。
  • ルーターの状態に置いたカウンターのようにメモリに持つ状態は、1 つのプロセスのものです。ホストがインスタンスを複数動かすときは、その状態をデータベースに置いてください。

Cloudflare Workers にデプロイする

このコマンドを実行するには、Cloudflare のアカウント、Rust 1.99 以降、Node.js 22 以降が必要です。Node.js は Cloudflare のコマンドラインツール wrangler を動かすためだけに使います。wrangler は npm でしか配布されていません。npx が最初の実行時にダウンロードするので、プロジェクトに package.json はありません。cargo generate はコンテナのときと同じように Workers のテンプレートを my-worker にコピーし、パッケージと Worker にその名前を付けます。

cargo install cargo-generate
cargo generate --git https://github.com/imaimai17468/hypersig templates/cloudflare --name my-worker
cd my-worker
npx wrangler dev
npx wrangler login
npx wrangler deploy

wrangler dev は Worker をビルドし、Workers と同じ実行環境 workerd で http://localhost:8787 に配信します。最初のビルドでは、クレートを wasm にコンパイルするツール worker-build と wasm の target をインストールするので、数分かかります。wrangler login はブラウザを開いて Cloudflare にログインします。wrangler deploy は Worker をアップロードして https://my-worker.<サブドメイン>.workers.dev のアドレスを表示します。workers.dev のサブドメインをまだ持たないアカウントでは、先にサブドメインの登録を求められます。

テンプレートの中身

ファイル役割
src/lib.rs下の Worker です。+1 のボタンはブラウザで数を増やし、もう 1 つのボタンは Worker で数を 2 倍にし、ストリームは Worker の時刻を 1 秒ごとに送ります。
Cargo.tomlworker-build がコンパイルするライブラリです。axum は tokio のネットワーク機能を使う既定の機能を外して使います。
wrangler.tomlWorker の名前と、ビルドのコマンドです。コマンドは worker-build がなければインストールしてから実行します。
rust-toolchain.tomlwasm の target を書いておくと、rustup が最初のビルドで既定のツールチェーンに追加します。
src/lib.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_service::Service as _;
use worker::send::SendFuture;
use worker::{Context, Delay, Env, HttpRequest, event};

#[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 Cloudflare Workers", 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 worker"),
                        ),
                )
                .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 worker's time, once a second.
fn clock((): (), (): ()) -> impl Stream<Item = Patches<Counter>> {
    stream::unfold((), |()| async {
        // `Delay` waits on a JavaScript timer, which is not `Send`. A worker
        // runs on one thread, so the wrapper never moves it to another.
        SendFuture::new(Delay::from(Duration::from_secs(1))).await;
        Some((Counter::now().patch(Timestamp::now()).into(), ()))
    })
}

#[event(fetch)]
async fn fetch(
    request: HttpRequest,
    _env: Env,
    _context: Context,
) -> worker::Result<axum::http::Response<axum::body::Body>> {
    let mut app = pages(Router::new())
        .hypersig_runtime()
        .action(DOUBLE, double)
        .stream(CLOCK, clock);
    Ok(app.call(request).await?)
}

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

サーバーのプロジェクトを Worker に移行するには、パッケージをバイナリから worker-build がコンパイルするライブラリに変え、main を上の fetch 関数に置き換えます。ページ、アクション、ストリームのコードはそのまま使えます。

ファイル変えること
Cargo.toml[lib] に crate-type = ["cdylib"] を追加します。axum は default-features = false にし、http と axum の機能を付けた worker と tower-service を追加し、tokio を依存から外します。
src/main.rsファイル名を src/lib.rs に変え、#[tokio::main] async fn main をテンプレートと同じ形の fetch 関数に置き換えます。fetch は同じ Router を組み立て、リクエストを渡します。
wrangler.toml、rust-toolchain.tomltemplates/cloudflare からコピーし、wrangler.toml の name を Worker の名前にします。

tokio を使うコードも書き換えが必要です。tokio::time::sleep で待つストリームは、上の時計と同じように worker::Delay で待ちます。ルーターの状態に置いた値は、次の節のとおり isolate より長くは残りません。そのあとプロジェクトで npx wrangler dev を実行してください。

Workers で変わること

  • リクエストごとに別の isolate で動くことがあるので、メモリに置いた値は次のリクエストで消えていることがあります。リクエストをまたいで共有する状態は KV、D1、Durable Object に置いてください。
  • axum は keep-alive のコメントを tokio のタイマーで送り、この target には tokio のタイマーがないので、ストリームはこのコメントを送りません。パッチの合間に待つストリームは worker::Delay で待ちます。JavaScript のタイマーは Send ではないので、SendFuture で包みます。
  • hypersig_runtime() はランタイムを圧縮せずに送ります。Workers の実行環境は Content-Encoding を付けた本文をもう一度圧縮するので、ビルド時に Brotli で圧縮したファイルを送ると、ブラウザには 2 回圧縮されたファイルが届きます。
  • Timestamp::now は JavaScript の Date.now() で時刻を読みます。この target では SystemTime::now がパニックするためです。