Tutorial 41 of 54 · for Rust developers who want a loader written in Rust · Markdown

Move a loader into Rust

You'll take one loader out of TypeScript and answer it from Rust. The page, the route and the tests don't change. At the end you'll load the page and see your Rust string in the markup.

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 from the app you built in 320, which has a /product/{id} route whose loader is TypeScript.

Find the name you're taking over#

The plan file names everything. Build the app and read the sources column:

$ fsr build shop/app --verbose
sources   $root                  lowered     routes/page.loader.ts
          product.$id            lowered     routes/product/[id]/page.loader.ts

product.$id is the name. A route directory product/[id] gives the source product.$id. You bind that string in Rust.

Add a Cargo project beside the app#

Up to now the stock fsr binary has been serving this. To write Rust you need your own binary. Put this Cargo.toml next to app/ and config/:

Cargo.toml
[package]
name = "shop"
version = "0.1.0"
edition = "2024"

[dependencies]
snapfire_fsr = "0"
snapfire_fsr_core = "0"
snapfire_fsr_host = "0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

A client on the mock transport needs no feature. Against a real service over HTTP turn on the host's http feature: snapfire_fsr_host = { version = "0", features = ["http"] }. [auth] needs auth, a .graphql client graphql and a .proto client grpc.

Write the override#

Here is src/main.rs in full. It compiles as written.

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

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 {
          let id = ctx.params.get("id").cloned().unwrap_or_default();
          let product: Data = [
            ("id".to_owned(), id.into()),
            ("name".to_owned(), "Chore coat".into()),
            ("blurb".to_owned(), "Answered by Rust.".into()),
            ("price_cents".to_owned(), 14800i64.into()),
          ]
          .into_iter()
          .collect();
          Ok([("product".to_owned(), Value::Map(product))].into_iter().collect())
        })
        .build()
    })
    .map_err(std::io::Error::other)?;

  print!("{}", host.report());
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}

Data is a ValueMap, which you build by collecting (String, Value) pairs. A nested object is a Value::Map. The keys have to match what the page destructures, because the page is unchanged and still expects product.

ctx.params is the same params the TypeScript loader read. ctx also carries query, the session cell and the service handle, so your Rust calls the services through the same registry and against the same contract.

Host::from_cwd takes the directory you run the binary in as the project root, not the app directory. It reads config/, the plan and the contracts from there. So the binary serves whichever project it runs inside: this one under cargo run, the deployed tree in 410.

Run it#

$ cargo run
routes    /                      plan file
sources   $root                  lowered
          product.$id            rust override

One row changed. Open /product/9 and the page renders "Answered by Rust." inside the same markup the TSX component produced. The component gets the same props and doesn't know they came from Rust.

Rules for claiming a name#

The host refuses to guess when the plan file and Rust both name something. Each conflict is a boot error that names the source.

.source("pricing", f) on a name the plan doesn't lower just binds it. That's how you add a source that has no TypeScript at all.

.source("product.$id", f) on a name the plan lowered is refused:

claimed by the plan file and by Rust; mark the Rust one as an override

Use .source_override instead. Every loader Rust replaces is then marked as an override in main.rs.

.source_override("products.$id", f) when no such source exists fails at boot. That's almost always a rename that left the override dangling. A dangling override would leave the TypeScript running while you believed Rust had taken it.

Override an action#

An action override takes the context and the decoded input and returns a Value. Chain it onto the builder in main, after .source_override(...) and before .build():

src/main.rs
.action_override("product.$id.addToCart", |_ctx, input| async move {
  let quantity = match &input {
    Value::Map(fields) => fields.get("quantity").cloned().unwrap_or(Value::Int(0)),
    _ => Value::Int(0),
  };
  Ok(Value::Map([("count".to_owned(), quantity)].into_iter().collect()))
})

input arrives as a Value, so you match on Value::Map to read a field off it. There is no .get() on Value itself.

Add a route in Rust#

.route binds a pattern the plan file doesn't have, so it adds a route and doesn't override anything. A Plan says which module renders and what fills the shell's slot. Add use snapfire_fsr::Plan; at the top of main.rs and chain the route onto the builder:

src/main.rs
.route("/about", Plan::of("shell#document").slot("content", Plan::of("routes/page.tsx#default")))

Render a module yourself#

An evaluator answers a set of module ids with a Rust renderer. It has one method, which returns a stream of chunks. Put this in main.rs above main:

src/main.rs
use futures_util::stream;
use snapfire_fsr_core::{ModuleId, Node};
use snapfire_fsr_runtime::evaluator::{Chunk, Evaluator, NodeChunks};

struct Banner;

impl Evaluator for Banner {
  fn evaluate(&self, _module: &ModuleId, _props: &Data) -> NodeChunks {
    let node = Node::raw("<p class=\"banner\">Rendered by Rust.</p>");
    Box::pin(stream::iter([Ok(Chunk::Node(node))]))
  }
}

Then chain it onto the builder with the module ids it answers:

src/main.rs
.evaluator(|m: &ModuleId| m.path == "src/Banner.tsx", std::sync::Arc::new(Banner))

That needs snapfire_fsr_runtime and futures-util in your Cargo.toml. snapfire_fsr_tera uses the same mechanism, which is how a Tera template and a React page compose into one document.

Read the report#

routes    /                      plan file
          /about                 rust
sources   $root                  lowered
          product.$id            rust override
actions   product.$id.addToCart  rust override

Calling Rust without taking a name#

Sometimes the loader is fine and one computation belongs in Rust. Mark an impl block instead. The build reads every .rs file under src/, so this can sit in main.rs above main. It needs snapfire_fsr_macros in your Cargo.toml:

src/main.rs
use snapfire_fsr_macros::native;

#[derive(Clone, Default)]
pub struct Digest;

#[native]
impl Digest {
  pub fn words(&self, bodies: Vec<String>) -> i64 {
    bodies.iter().map(|b| b.split_whitespace().count() as i64).sum()
  }
}

Register it on the builder under the name the TypeScript uses:

src/main.rs
.native("digest", Arc::new(Digest))

The build writes declarations into generated/native.d.ts, so a loader reads native off its context and calls the method with its arguments as an object:

app/routes/product/[id]/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ params, services, native }: Ctx<"/product/{id}">) {
  const product = await services.shop.getProduct({ id: params.id });
  return { product, words: native.digest.words({ bodies: [product.blurb] }) };
}

The work happens in Rust. A native call is synchronous, so it needs no await. This needs no override and doesn't take a source name.

Services from Rust#

Rust calls services through the same registry as the TypeScript loader did, checked against the same contract document.

Next up: 510. Add FSR to an existing Rust service.