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
Straight from crates.io. No Node, 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
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. That string is what you bind 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/:
[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"] }Write the override
src/main.rs, in full. This compiles.
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(env!("CARGO_MANIFEST_DIR"))
.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
}Three things to notice.
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 same services through the same registry with the same contract.
Host::from takes the project root, not the app directory. It reads config/, the plan and the contracts from there.
Run it
$ cargo run
routes / plan file
sources $root lowered
product.$id rust override
There it is. One row changed. Open /product/9 and the page renders "Answered by Rust." inside the same markup the TSX component produced, because the component never knew.
The three rules for claiming a name
The host refuses to guess when the plan file and Rust both name something. Every case is a boot error naming the name.
Adding is additive. .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.
Replacing is deliberate. .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 and it works. Whoever reads main.rs then sees every place TypeScript was overruled.
Overriding nothing is refused. .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
Same shape, one more argument. An action override takes the context and the decoded input, then returns a Value.
.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 the file system never described
.route binds a pattern the plan file doesn't have, so it's additive rather than an override. A Plan says which module renders and what fills the shell's slot.
.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. One method, returning a stream of chunks.
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))]))
}
}.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. It's the same seam snapfire_fsr_tera uses, which is how a Tera template and a React page compose into one document.
The report with all four
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:
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()
}
}The build writes declarations into generated/native.d.ts, so the TypeScript loader calls ctx.native.digest.words(...) with types and the work happens in Rust. No override, no name taken.
The contract never moves
What never moves is the contract. Rust calls services through the same registry checked against the same document, because the boundary is the artifact rather than the language either side of it.
No rewrite
One name moved. The route, the page, the props type and the tests are untouched. The report says in one row which language answers what.
Next up: 140. Add FSR to an existing Rust service.