日本語

Cloudflare Workers

The Workers template compiles the same pages, actions, and streams to wasm, and the worker hands each request to the axum Router.

Deploy from the template

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.