Tutorial 42 of 54 · for platform engineers who already have a Rust service · Markdown

Add FSR to an existing Rust service

The FSR host is a tower::Service, so you can mount it inside a binary you already have, with your own runtime, middleware and listener.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start a server#

src/main.rs
use std::sync::Arc;
use snapfire_fsr_host::Host;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?;
  print!("{}", host.report());
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}

That's a complete FSR server. Host::from_cwd reads config/, the plan and the contracts. build binds every name and refuses to start if one is unanswered.

Print the report. It's the same table fsr build --verbose printed with the host's rows added.

Bind the listener yourself#

serve binds an address. serve_listener takes one you already have, which is how a test picks port zero:

src/main.rs
use std::sync::Arc;
use snapfire_fsr_host::Host;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?;
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  println!("listening on {}", listener.local_addr()?);
  Arc::new(host).serve_listener(listener).await
}

Nest it in a router you already have#

The host is a tower::Service. An existing axum router nests it under a prefix and keeps its own middleware. This needs axum in your Cargo.toml:

src/main.rs
use std::sync::Arc;

use axum::{routing::get, Router};
use snapfire_fsr_host::Host;

async fn healthz() -> &'static str {
  "ok"
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?;
  let host = Arc::new(host);
  let app = Router::new()
    .route("/healthz", get(healthz))
    .nest_service("/shop", host.service());
  let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
  axum::serve(listener, app).await
}

Your API, auth layer, rate limiter and tracing middleware all still apply.

Actix works too. Turn on the host's actix feature and add actix-web = "4", then run main on actix's runtime:

src/main.rs
use std::sync::Arc;
use snapfire_fsr_host::Host;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?;
  snapfire_fsr_host::actix::serve(Arc::new(host), ("127.0.0.1", 8080)).await
}

Run a request with no socket#

handle runs the whole edge for one request, cookies included, with nothing bound. You can use it for integration tests and to embed the host somewhere that isn't HTTP.

Terminate TLS when there's no proxy#

Most deployments put a proxy in front and the host never sees a certificate. For the one that doesn't, the tls feature makes the hyper listener terminate. ALPN picks HTTP/2 or HTTP/1.1 per connection.

config/app.toml
[server]
http2 = true

Reload without dropping connections#

Everything a request reads is one set of tables the host swaps whole: the plan, the contracts, the clients, the head, the static roots, the locales, the identity flow.

host.reload() rereads the configuration, the plan and the contracts and swaps the tables. This main does it on every SIGHUP, which needs tokio's signal feature:

src/main.rs
use std::sync::Arc;

use snapfire_fsr_host::Host;
use tokio::signal::unix::{signal, SignalKind};

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd().and_then(|b| b.build()).map_err(std::io::Error::other)?;
  let host = Arc::new(host);

  let reloading = host.clone();
  let mut hangup = signal(SignalKind::hangup())?;
  tokio::spawn(async move {
    while hangup.recv().await.is_some() {
      match reloading.reload() {
        Ok(report) => print!("{report}"),
        Err(e) => eprintln!("reload refused: {e}"),
      }
    }
  });

  let listen = host.listen().to_owned();
  host.serve(&listen).await
}

A host built from its configuration alone reloads by reading it again. One you gave overrides, services or an evaluator by hand needs .reloader(...) on the builder, a function that builds it again with those added.

A request already running finishes on the tables it started with. The next one sees the new ones. Sessions aren't part of the tables, so nobody is signed out by a deploy. A reload whose [session] settings differ from the running ones is refused rather than applied.

Call it from a SIGHUP handler for config reload or from your control plane. 310 uses the same machinery to swap a mounted site while the process keeps serving.

Take names back in Rust#

The builder binds a name to whatever answers it. That can be your code. With the shop app from 500, the overrides chain between Host::from_cwd() and .build():

src/main.rs
use std::sync::Arc;

use snapfire_fsr::Plan;
use snapfire_fsr_core::{Data, Value};
use snapfire_fsr_host::Host;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd()
    .and_then(|builder| {
      builder
        .source_override("product.$id", |_ctx| async move { Ok(Data::default()) })
        .action_override("product.$id.addToCart", |_ctx, _input| async move { Ok(Value::Null) })
        .route("/about", Plan::of("shell#document").slot("content", Plan::of("routes/page.tsx#default")))
        .build()
    })
    .map_err(std::io::Error::other)?;
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}

500 covers each of these in full, including why replacing something is spelled _override and why overriding nothing is a boot error.

Next up: 520. Serve Tera templates from Rust, which renders pages from Tera templates.