日本語

Vercel

The Vercel template runs the same pages, actions, and streams as a Vercel Function, where VercelLayer hands each request to the axum Router.

Deploy from the template

These commands need a Vercel account, Rust 1.99 or later, and Node.js 22 or later. Node.js runs vercel, Vercel's command-line tool, which npx fetches on first use. cargo generate copies the Vercel template into my-app and names the package after it.

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 starts the function at http://localhost:3000. Outside Vercel, vercel_runtime::run listens on 127.0.0.1 at the port in VERCEL_DEV_PORT, or 3000, and hands each request to the same VercelLayer a deployment uses. vercel login signs the command-line tool in to Vercel, which keeps its own login apart from the browser's, and vercel deploy stops with No existing credentials found until it has run.

The first vercel deploy asks which account and project the directory belongs to, creates the project, and links the directory to it in .vercel, which the template's .gitignore leaves out. It then uploads the source, and Vercel builds it on its own servers with Rust from rustup, so no binary is uploaded. --prod publishes to the project's production address, https://my-app.vercel.app where the name is free, and prints it. Without --prod the deployment is a preview at an address of its own, which a project with Deployment Protection shows only to members signed in to Vercel. A Git repository connected to the project deploys a preview on each push to a branch, and production on each push to the main branch.

What the template holds

FileWhat it does
api/app.rsThe function below. The +1 button counts in the browser, the second button doubles the count in the function, and a stream sends the function's time every second.
Cargo.tomlThe binary app, built from api/app.rs, which Vercel turns into the function at /api/app, with vercel_runtime and its axum feature.
vercel.jsonSends every path to /api/app, so the axum Router sees each request with its own path, and gives Vercel an empty public directory to serve as static files, which the last section explains.
.vercelignoreKeeps target out of the upload.
rust-toolchain.tomlNames Rust 1.99, which rustup installs on Vercel's build image, whose own Rust is older than hypersig needs.
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
}

Turn a server project into a Vercel Function

A server project becomes a Vercel Function when its main hands the Router to vercel_runtime::run in place of axum::serve. The function runs on tokio as the server did, so the pages, actions, and streams stay as they are, tokio::time::sleep included.

FileWhat changes
Cargo.tomlAdd vercel_runtime with the feature axum, and tower. Add a [[bin]] named app whose path is api/app.rs.
src/main.rsMove it to api/app.rs, and replace the listener and axum::serve with the template's last line, which wraps the Router in VercelLayer and runs it.
vercel.json, .vercelignore, rust-toolchain.tomlCopy them from templates/vercel.

What changes on Vercel

  • Vercel serves the files of its output directory before a rewrite applies, and a project that names none has the project's root as that directory. There, Cargo.toml, Cargo.lock, and every file outside api/, the Rust source under src/ included, are public at their paths, and .env was the one file tried that was not. The template's vercel.json names public as the output directory, which the build command creates holding only an empty .keep, so every other path reaches the function. Keep those two fields in a vercel.json of your own.
  • Vercel ends a request at the function's maximum duration, which counts the time a stream stays open. The limit is 300 seconds by default and on the Hobby plan, and the Pro plan raises it to 800 seconds with maxDuration. At the limit Vercel closes the response before the hs-end event the server sends when the handler's stream finishes, so a call with reconnect() counts the stream as broken off and opens it again after 1 second. On a deployment of this template, the clock's stream opened again 301 seconds after the page loaded and the clock kept going. The cut is a failure like any other: it goes to the console and to the hs-error event, and the call's error signal holds it until the stream opens again, so a page that shows a message on hs-error shows one each time the limit passes.
  • Vercel may run more than one instance of the function and stops an idle one, so a value kept in memory may be gone at the next request. Keep the state requests share in a database.
  • VercelLayer reads the whole request body before the Router sees it, so a file upload read in chunks arrives in memory at once. Vercel refuses a request body over 4.5 MB.
  • hypersig_runtime() sends the runtime compressed with Brotli, with Content-Encoding: br. Vercel compresses it again on the way, so the bytes the browser receives differ from the ones the function sent, but they are compressed once and decode to the same runtime.