Skip to main content

Crate ferrovue

Crate ferrovue 

Source
Expand description

Vue single-file components compiled to Rust render functions.

ferrovue lets a Rust server render a Vue 3 component’s HTML with no JavaScript at run time, while the browser hydrates that HTML with the same .vue file. It has two halves, released together under the same version number:

What it isWhere
The compilerReads <script setup lang="ts"> components and writes one Rust module per component (TypeScript, runs on Node)npm: ferrovue
The runtimeThis crate: what the generated modules call, from escaping to <RouterLink> matchingcrates.io: ferrovue
 Greeting.vue ──(ferrovue compiler, Node)──▶ greeting.rs ──(your Rust server)──▶ HTML ──▶ Vue hydrates it

§The guarantee

ferrovue doesn’t interpret templates. It takes what @vue/compiler-sfc compiles a template to for server rendering, straight-line _push calls plus a few @vue/server-renderer helpers, and translates that small, closed vocabulary into Rust that writes the same bytes. Vue still decides element structure, attribute order, whitespace and fragment markers.

A single differing byte would be a hydration mismatch in the browser, so anything the compiler can’t translate exactly is a compile error that names the component and the construct. It never guesses. The repository’s conformance suite renders every fixture with Vue’s renderToString and with the generated Rust and compares the bytes; Vue then hydrates the recorded HTML with no mismatch. A randomised differential tester does the same with components nobody wrote by hand.

§Quick start

The guide covers each step in detail; this is the whole path in brief.

1. Install both halves. The generated structs derive serde::Serialize, and the generated mod.rs has a test-only helper that reads props from JSON.

pnpm add -D ferrovue                          # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json

2. Configure. ferrovue.config.json at the project root names the components and where the Rust goes. Everything in out is replaced on each run.

{
  "components": "client/components",
  "out": "src/generated"
}

3. Write a component.

<!-- client/components/Greeting.vue -->
<script setup lang="ts">
defineProps<{ name: string; unread: number; note?: string }>();
</script>

<template>
  <p class="greeting">Hello, {{ name }}!<b v-if="unread">{{ unread }} new</b><i v-if="note">{{ note }}</i></p>
</template>

4. Generate.

pnpm ferrovue           # writes src/generated/greeting.rs and src/generated/mod.rs
pnpm ferrovue --check   # in CI: exits 1 if the committed modules are stale

5. Include the modules and render. Declare the module once, with #[rustfmt::skip] so that ferrovue --check keeps comparing the files byte for byte:

#[rustfmt::skip]
mod generated;

Each component’s module has a Props struct, built with new and a setter per optional prop, and three ways to render:

use generated::greeting::{self, Props};

// `new` takes the required props; each optional one has a setter.
let props = Props::new("Ada", 3).note("welcome back");

// The markup alone, which the client never hydrates.
let html = greeting::html(&props).into_string();
assert_eq!(html, r#"<p class="greeting">Hello, Ada!<b>3 new</b><i>welcome back</i></p>"#);

// An island: the same markup in an element the client mounts on, with the props as JSON.
let island = greeting::island(&props).into_string();
assert!(island.starts_with(r#"<div data-island="Greeting" data-props="{&quot;name&quot;:&quot;Ada&quot;"#));

// Or straight into a buffer you already hold: no allocation of its own.
let mut page = String::from("<!doctype html><body>");
greeting::render(&mut page, &props);

// Every interpolated value is escaped.
let hostile = greeting::html(&Props::new("<script>", 0)).into_string();
assert_eq!(hostile, r#"<p class="greeting">Hello, &lt;script&gt;!<!----><!----></p>"#);

A component with <style scoped> writes the same data-v- ids as Vue’s server renderer, computed as @vitejs/plugin-vue computes them; see guide::scoped_styles.

On the client, the same component hydrates the page: mountIslands from ferrovue mounts every island, mountPage hydrates a layout and the parts a Page recorded in its slots, or the page’s own Vue app hydrates the whole document. See guide::islands_and_hydration.

§What the generated code calls

You rarely call most of this crate yourself; the generated modules do. The parts you do touch are the values a render needs besides its props.

You buildTo render a component that
Slot, a generated Slots struct, scoped-slot closuresrenders <slot> or <RouterView>
Router (from the generated route_table::router()), then a Route per requestrenders <RouterLink> or reads useRoute()
A generated stores::Storesreads Pinia state
I18n (from the generated i18n::i18n(locale))calls $t or useI18n()
Teleportsrenders <Teleport>
A generated provides::Provides, empty or holding what the client app gives app.providecalls provide or inject, or renders one that does (guide::provide_inject)
Headcalls useHead or useSeoMeta, or renders one that does (guide::head)
BasicHtml, InlineHtml, Sanitised (the ammonia feature), or your own type implementing TrustedHtmlrenders v-html

A prop declared Record<string, T> is a Record, which keeps JavaScript’s order of keys.

and these, to assemble a page:

  • Html: a component applied to its props, borrowed (html(&props)) or held (into_html(props)), written into a buffer with Html::render_to or returned with Html::into_string;
  • Html::hydrate with a Hydrate: an island that hydrates once visible, when the browser is idle, on interaction or when a media query matches;
  • Head::render: the tags and attributes of the page’s <head>, <html> and <body>;
  • state_script_into: the stores’ state for the client to hydrate from;
  • hole and split_holes: render a layout once and stream its parts;
  • HtmlStream (the stream feature): a page with holes as an HTTP body, each hole’s content sent as soon as it is ready;
  • escape_into: text written into your own markup as Vue would write it.

and, in tests, conformance! and check_fixtures, which render an application’s fixtures through its generated code and compare the bytes with the HTML recorded from Vue (guide::testing).

The rest are Rust twins of JavaScript and Vue routines that generated code calls so that it writes what Vue writes. Those a hand-written twin or page may call too are documented here:

The others are #[doc(hidden)]: JavaScript’s string methods counted in UTF-16 code units (js_length, js_slice, js_split and the rest the strings page lists), Number, parseInt, parseFloat and JSON.stringify (js_number, js_parse_int, js_parse_float, js_json_number, js_json_string), dates as time values in UTC (js_date_parse, js_time_clip, js_to_iso_string and js_utc_full_year to js_utc_milliseconds), a list’s slice (js_slice_range, js_slice_items), and Vue’s class, style, scope id, slot outlet, teleport and v-html helpers (class_into, class_object, class_names, style_text_into, merge_props, scope_attrs, slot_into_slotted, scoped_slot_into, scoped_slot_into_slotted, teleport_into, trusted_into). Only generated code calls them, so their signatures follow the version of generated code (the __compat! check at the top of a generated mod.rs) and may change in any release; the generated_code page says what is stable.

§Feature flags

FeatureDefaultWhat it adds
routeronRouter, Route, Link, Query, RouteDef and query_into, for components that render <RouterLink> or <RouterView> or read useRoute(), from the ferrovue-router crate
i18nonThe i18n module and I18n, for components that call $t or useI18n(), from the ferrovue-i18n crate
maudoffimpl maud::Render for Html, so (greeting::html(&props)) can go straight into a maud::html! page
streamoffHtmlStream: a page rendered with holes as a stream of byte chunks, each hole filled by a future
axumoffstream, and IntoResponse for Html and HtmlStream, so a handler returns greeting::into_html(props) (guide::web_frameworks)
actix-weboffstream, and Responder for Html and HtmlStream
ammoniaoffSanitised, HTML cleaned by ammonia, to name as trustedHtml for v-html props (guide::escaping shows a policy of your own, with ammonia 4 as your own dependency)
dioxusoffThe dioxus module: Html::to_element and impl IntoDynNode for Html, so {greeting::island(&props)} can go straight into Dioxus 0.7’s rsx!, and dioxus::state_script; the guide’s dioxus page shows a page

An application with neither routes nor translations can turn the default features off (default-features = false) and builds neither ferrovue-router nor ferrovue-i18n. Generated code is the same either way: it names the router and translations only when the project configures them.

§Crates

The runtime is four crates, released together at one version; this one re-exports the others at its root, which is the one path generated code and this documentation use:

CrateWhat it holds
ferrovueThis crate: what generated code calls, and the framework integrations
ferrovue-coreescape_into, and JavaScript’s numbers (push_int, push_number, Js, …), which the other three share
ferrovue-routerRouter and what goes with it (the router feature)
ferrovue-i18ni18n and I18n (the i18n feature)

§Rust version

ferrovue supports the latest stable Rust (the rust-version in its manifest). The generated code uses let-chains, so the crate that includes it must use edition 2024.

§Versions

Use the same version of the npm package and of this crate: generated code is written against the runtime of its own release. Byte-identical output also depends on Vue’s own SSR compiler, so the npm package compiles with an exact @vue/compiler-sfc, and its fixtures are recorded from one version of Vue and of each integration. Your client may use any later patch of the same minor (vue ~3.5.44); the repository’s README lists the range of every peer.

Modules§

dioxusdioxus
Components and islands as Dioxus elements, for a page that Dioxus renders on the server (dioxus-ssr, or a fullstack app). Dioxus 0.7.
guide
A guide to ferrovue: from a .vue file to a page a browser hydrates.
i18ni18n
vue-i18n’s t(), for components that call $t or useI18n().t: part of ferrovue’s runtime.

Macros§

conformance
Write the test that renders every fixture through the generated render_json and compares the bytes with the HTML Vue recorded: check_fixtures over a directory relative to the crate’s manifest, failing when fewer than at_least fixtures (one when not given) are found.

Structs§

Attrs
What a parent passes a component beyond its props ($attrs, in the order given) and the scope ids its root inherits, data-v-… each, which Vue keeps after them.
BasicHtml
Untrusted text with a few formatting tags: a TrustedHtml for v-html that needs no sanitiser.
Chunks
The chunks of a client build, read from Vite’s manifest (.vite/manifest.json, written by vite build with build.manifest: true): which scripts an island needs, so the page can name them up front with <link rel="modulepreload"> in place of the browser finding them one import at a time once the client runs.
Head
The head of one render: what every useHead and useSeoMeta in the component tree asked for, in the order they ran. A page renders its components first, then writes render into the document. It reproduces @unhead/vue’s server head: its tag order, deduplication, title template and escaping.
HeadHtml
What unhead’s server renderer writes for a page’s head: renderSSRHead’s five strings.
Html
A component applied to its props, ready to be written: what a generated html() or island() returns, borrowing the props, and into_html() or into_island(), holding them.
HtmlStreamstream
A page rendered with holes, sent in pieces: the markup up to the first hole at once, then each hole’s content as soon as it and every hole before it are ready, each followed by the markup up to the next hole. guide::streaming explains holes.
I18ni18n
The messages and the locale one request renders in.
InlineHtml
Untrusted text with a few inline formatting tags: a TrustedHtml that v-html may write inside a <p>.
Js
A number written as JavaScript writes it, for format! in generated code: ${n} in a template literal, "#" + n, n.toString().
Linkrouter
A <RouterLink>’s to, resolved: what Route::link, Route::link_named and Route::link_path return.
ManifestError
A manifest or a list of islands Chunks::from_manifest cannot read.
Page
A page the client hydrates as one app: a layout whose slots hold components rendered from their props. Each part goes into a slot as a Part, made from the same Html value that writes its markup, so the record of the page the client rebuilds is made from the values that rendered it. mountPage from ferrovue/client reads the record back and hydrates the layout. guide::islands_and_hydration shows a whole page, client included.
PageHole
A slot of a Page whose parts are rendered later, as an HtmlStream (the stream feature) hole is: give the layout its slot, and fill it once its data is ready. A hole dropped without being filled is recorded as empty, as HtmlStream leaves a hole given no content empty.
PageRecord
The record of a rendered Page, which the client reads back: script_into for a page written at once, script for a streamed one.
PageScript
The record’s <script> once every PageHole of the page is filled or dropped: the content of the last hole of an HtmlStream (the stream feature). Made by PageRecord::script.
PageSlot
The parts of one slot of a Page, ready to be given to the layout with slot.
Part
One component in a slot of a Page: its name, as the client’s loaders know it (the generated NAME), and its Html, which writes the markup and gives the record its props.
Preloads
The <link rel="modulepreload">s of one page, each URL written once however many islands need it. Made by Chunks::preloads.
Record
A Record<string, T> (or { [key: string]: T }) prop: entries in the order JavaScript gives an object’s keys, which is what v-for and Object.keys walk. Keys that are array indices ("0", "12") come first, in numeric order, then the others in the order they were added; a key given twice keeps its first place and its last value, as JSON.parse keeps it.
Routerouter
Where the reader is, which is what every link on the page is compared with.
RouteDefrouter
A route as the routes file lists it: a path, its name if it has one, whether it shows a component, and the routes nested in it, rendered by the <RouterView> in its component.
Routerrouter
The routes, in the order vue-router tries them.
Sanitisedammonia
HTML cleaned by ammonia: a TrustedHtml for v-html, with the ammonia feature.
Slot
What a parent puts in one of a component’s slots.
Teleports
The teleported content of one render, by target, in the order Vue collects it.

Enums§

Attr
One attribute’s value, as Vue holds it once the parent’s virtual node is made: createVNode has already normalised a class given as an array or an object, and a style given as an array.
HeadValue
A value given to useHead, as JavaScript holds it: what the generated code builds from a component’s useHead({ … }), and what a page pushes with Head::push.
Hydrate
When the client hydrates an island, and so when it fetches the island’s code: what Html::hydrate writes as the island’s data-hydrate. An island without one is hydrated as soon as mountIslands runs.
Queryrouter
One value of route.query, as vue-router parses it: a key may be absent, given without a value (?flag, which is null), given once, or given more than once, which makes an array.

Traits§

TrustedHtml
HTML that is safe to write into a page as it is: what v-html may render.

Functions§

attrs_into
ssrRenderAttrs(mergeProps(...sources)) for an element given fallthrough attributes, with the scope ids written after the attributes the first ids_at + 1 sources give, where Vue’s _attrs, which holds them as keys after the attributes, sits among the sources.
check_fixtures
Render every fixture under fixtures with render_json and compare the bytes with the HTML Vue recorded beside it: the Rust half of an application’s conformance suite, of which conformanceSuite in ferrovue/testing is the Vue half.
escape_into
escapeHtml: ", &, ', < and >, and nothing else.
hole
A slot whose content the caller writes itself, later: render with holes, split_holes the output, and write the pieces with each hole’s content between them, which is how a page streams its parts in the order they are ready. A hole is content to the slot, so its fallback never shows. guide::streaming shows a whole streamed page.
js_max
Math.max of two numbers: NaN if either is, where Rust’s f64::max ignores a NaN.
js_min
Math.min of two numbers: NaN if either is, where Rust’s f64::min ignores a NaN.
js_round
Math.round: the nearest integer, a half rounding up toward +∞ (-2.5 to -2, where Rust’s f64::round gives -3), and -0 from -0.5 up to zero, which 1 / Math.round(x) shows.
js_to_fixed
Number.prototype.toFixed(digits): the number rounded to digits places, from its exact binary value. An exact tie rounds away from zero, where Rust’s formatting rounds it to even; at 10²¹ and beyond, JavaScript writes the number as String(x) does.
passed_attrs_into
ssrRenderAttrs(attrs) of a single list, merged with nothing: each value as the parent gave it (a class not trimmed for mergeProps, a style given as text written as it is), then the scope ids.
push_int
String(n) for an integer, which is what toDisplayString and escapeHtml make of a number.
push_number
Number.prototype.toString(): JavaScript’s shortest round-trip digits, laid out as ECMAScript lays them out: 0.30000000000000004, 1e+21, 1.5e-7, NaN, -Infinity.
query_intorouter
stringifyQuery, one key and value at a time: key=value, each encoded as vue-router encodes them, after a & when search already holds a pair.
slot_into
ssrRenderSlot: the slot’s content between fragment markers, or its fallback when it was given none, or only comments, which is what Vue reads as nothing.
split_holes
The pieces of a render between its holes, in order: one more than there were holes. The pieces borrow from rendered.
state_script_into
The stores’ state as the client reads it back before it hydrates: a <script type="application/json">, which a script-src 'self' policy does not run, so the page needs no nonce for it. <, >, & and the two line separators are written as JSON escapes, so no value can end the element or be read as markup inside it.