日本語

Deploying

A template gives a project that deploys as it is: a container image for a container host, or a worker for Cloudflare Workers. Each takes a few commands from an empty directory to a running application.

Deploy in a container

These commands need Rust 1.99 or later and Docker. cargo generate copies the server template into my-app and names the package after it, and the last two lines build the image and run it.

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

Open http://localhost:3000. The project is the quick start counter, listening on every interface at the port in PORT, with a Dockerfile that builds the binary in one stage and copies it into a small image in the next. The browser runtime is compiled into the binary, so the image holds the binary alone. A host that builds from a Dockerfile, such as Fly.io, Cloud Run, or Render, takes the project as it is, and a host that runs an image takes the one built here, pushed to its registry.

Add the Dockerfile to a project you have

The template's Dockerfile names no package, so it builds any project whose package has one binary. Download it to the directory that holds Cargo.toml, with a .dockerignore that keeps target out of the build.

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

A main that binds 127.0.0.1, as the examples in this repository do, cannot be reached from outside the container. Listen on every interface at the port in PORT instead.

// 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

The Dockerfile builds with cargo install --path ., which fails at the root of a workspace, because the root holds no package. There, change . to the package's directory. The image's Rust is 1.99, so raise the FROM line where the project's rust-version is newer.

What the host has to allow

  • A stream holds its request open for as long as it sends patches. A host that ends a request after a time limit, as Cloud Run does, closes the stream at that limit, so raise the limit, and a call with reconnect() opens the stream again.
  • State kept in memory, such as a counter in the router's state, belongs to one process. When the host runs more than one instance, keep that state in a database.

Deploy on Cloudflare Workers

These commands need a Cloudflare account, Rust 1.99 or later, and Node.js 22 or later. Node.js runs wrangler, Cloudflare's command-line tool, which is published only on npm, and npx fetches it on first use, so the project has no package.json. cargo generate copies the Workers template into my-worker and names the package and the worker after it, as it does for the container.

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 builds the worker and runs it at http://localhost:8787 in workerd, the runtime Workers use. The first build installs worker-build, the tool that compiles the crate to wasm, and the wasm target, so it takes a few minutes. wrangler login opens the browser to sign in to Cloudflare. wrangler deploy uploads the worker and prints its address, https://my-worker.<your subdomain>.workers.dev, and on an account without a workers.dev subdomain it asks to register one first.

What the template holds

FileWhat it does
src/lib.rsThe worker below. The +1 button counts in the browser, the second button doubles the count on the worker, and a stream sends the worker's time every second.
Cargo.tomlA library that worker-build compiles, with axum without its default features, which need tokio's networking.
wrangler.tomlThe worker's name, and the build command, which installs worker-build when it is missing and runs it.
rust-toolchain.tomlNames the wasm target, which rustup adds to the default toolchain on the first build.
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?)
}

Turn a server project into a worker

A server project becomes a worker when its package turns from a binary into a library that worker-build compiles, and its main into the fetch function above. The pages, actions, and streams stay as they are.

FileWhat changes
Cargo.tomlAdd [lib] with crate-type = ["cdylib"]. Give axum default-features = false, add worker with the features http and axum and tower-service, and remove tokio.
src/main.rsRename it to src/lib.rs, and replace #[tokio::main] async fn main with a fetch function like the template's, which builds the same Router and hands it the request.
wrangler.toml, rust-toolchain.tomlCopy them from templates/cloudflare, and set name in wrangler.toml to the worker's name.

Code that uses tokio changes too. A stream that waits with tokio::time::sleep waits with worker::Delay, as the clock above does, and state in the router's state does not outlast the isolate, as the next section says. Then npx wrangler dev runs the project.

What changes on Workers

  • Requests may run in different isolates, so a value kept in memory may be gone at the next request. Keep the state requests share in KV, D1, or a Durable Object.
  • A stream sends no keep-alive comments, because axum times them with tokio, which has no timer on this target. A stream that waits between patches waits with worker::Delay, wrapped in SendFuture because a JavaScript timer is not Send.
  • hypersig_runtime() sends the runtime uncompressed. The Workers runtime compresses a body that names a Content-Encoding again, so the file compressed with Brotli at build time would reach the browser compressed twice.
  • Timestamp::now reads the clock with JavaScript's Date.now(), because SystemTime::now panics on this target.