LearnTesting and tooling
Rust, in depth
You never need Rust to create, run, build or check a weft app, on any platform. Rust is recommended for two things, and required for neither: it's the language weft's compiler is written in, which reaches you prebuilt; and it's a language you can choose for a server function that does heavy CPU work. This page covers both, so you know what's happening underneath and when it's worth writing Rust yourself.
The compiler, from the outside
Every .tsx file in a weft app goes through one compiler, crates/weft-compiler in the weft repository. It's written in Rust on top of Oxc, a fast JavaScript and TypeScript toolchain. You meet it in three places:
weft devandweft build, through the Vite plugin, which compiles each component as it's served or bundled.weft check, which compiles every component in check mode and reports what it finds.- Your editor, through the language server, which shows the same diagnostics as you type.
All three call the same code, so a diagnostic you see in the editor is the one weft check and the build report.
Native addon or WebAssembly
The compiler is built two ways from one crate: a Node addon for each common platform, and a WebAssembly module that runs anywhere. When weft starts, it takes the fastest one that's there:
- a native addon built in a checkout of the weft repository, if there is one;
- the prebuilt addon for this platform, an optional dependency a normal install adds by itself;
- the WebAssembly build that ships inside
@weft-js/core.
The output is the same byte for byte, whichever runs. weft's own test suite holds the two to that, so the addon only changes how long a build takes, never what it produces. weft doctor tells you which you have:
weft doctorIt prints the weft version and runtime, a line such as compiler: native addon (@weft-js/compiler-…) or compiler: WASM, same output as the addon, and whether cargo is on your PATH, adding that weft doesn't need it. weft check --verbose prints the same compiler line after its report.
The compiler, from the inside
You don't need this section to write weft. Read it when you want to know why a diagnostic fires, or why some patterns cost nothing at run time.
Each file is parsed once. Then four passes run over that one tree:
- Analyze reads the program with its scopes and symbols. It finds components, their props and their boxes, the
.mapcalls in JSX, and what each read and write touches. This is where most W-codes come from: a box rendered without.value(W015), a prop copied intostate(W005), a list item rebuilt with a new identity (W014), a row that reads the whole of a shared object (W017). It produces a plan and the diagnostics, and writes nothing. - Transform applies the plan to the same tree. Destructured props become live reads of
props, so({ name }) => <p>{name}</p>keeps following the parent. A keyed.mapin JSX becomes aForthat keeps each row by its key. Style groups lower to plain attributes. - Emit lowers JSX to code against weft's runtime. For the browser, each element tree becomes a template that's parsed once and cloned for each use, with one small binding for each dynamic part and delegated events. For the server, it becomes code that writes HTML.
- Facts for the rest of the project: what each top-level binding needs from the browser (so partial hydration can decide which components are islands), style groups, and where boxes travel through props.
The fourth pass is why some checks see across files. One file's compile can tell that a box built with spread items leaves through a prop; another's can tell that a prop is rendered as a keyed list. weft check joins those facts and reports W014 where they meet, even though no single file had both halves.
Deep diveWhy Rust, and why Oxc?
A compiler for an agent-first framework runs constantly: on every save, on every weft check an agent runs to see if its change is done, and in the editor on every keystroke. Speed there is the difference between a check you run and one you skip. Oxc parses TypeScript and JSX with full scope and symbol information in a fraction of the time a JavaScript parser takes, and Rust compiles to both a native addon and WebAssembly from the same source, which is what makes the "same output either way" promise cheap to keep.
The cost is that the compiler isn't JavaScript you can patch in node_modules. The diagnostics make up for that: each says what it found, why it matters and how to fix it, and many come with an autofix that weft fix applies.
Building the compiler yourself
This is for working on weft itself, in a checkout of the repository, not for building an app. npm run setup at the repository root builds everything once, the compiler included. After you change the crate, rebuild the addon and the WebAssembly module from packages/weft:
cd packages/weftnpm run build:nativeThe crate pins its Rust version in rust-toolchain.toml, and rustup picks it up by itself. In a checkout, weft refuses to load an addon that's older than the crate's source, with a message that says to run npm run build:native, so you can't test a stale compiler by accident.
Server functions in Rust
That's the Rust you don't write. The rest of this page is about the Rust you might.
A server function can have a Rust body. The client doesn't change. It keeps the same import, the same call, the same cache, tags and typed errors, and Zod stays the only schema. Only the body moves.
When it's worth it
Reach for Rust at about a millisecond of CPU per call, or on byte payloads such as search, parsing or images. For database reads and writes, TypeScript is the better fit: the time goes to the database, and a crossing to Rust only adds to it. If you're not sure, measure the TypeScript version first. Many functions that look heavy aren't.
native() or remote()
There are two ways to run the Rust:
native('media') | remote('rust://search') | |
|---|---|---|
| Runs | in the server's process: an addon, or WASM where one can't load | in a sidecar process |
| Good for | one hot CPU function | many functions, IO, or its own state |
| Each call costs about | 10–30 µs | 50–80 µs |
| A hang costs | a worker thread until it returns | nothing here: the sidecar can be killed |
The Rust code is the same either way. Moving between them is a line in src/server/rust.ts and the glue at the bottom of the crate.
weft add rust
weft add rust writes a working example: a crate, its module and one function. The name is up to you; it defaults to media:
weft add rust media # native(): an addon in the server's processweft add rust search --remote # remote(): a sidecarThis is the one part of weft that needs a Rust toolchain (cargo, from rustup.rs), and only for this crate. Here's what it writes for media. First, the one place Rust targets are named:
// src/server/rust.tsimport { native } from '@weft-js/start/rust'export const media = native('media')Then the function, declared in TypeScript as any other, with impl where a run would be:
// src/server/digest.tsimport { query } from '@weft-js/start'import { z } from 'zod'import { media } from './rust'export const digest = query({ input: z.object({ text: z.string().max(1_000_000) }), output: z.object({ bytes: z.int(), hash: z.string() }), impl: media,})output is required on an impl: it's the whole contract for what comes back, and TypeScript checks every answer against it.
And the body, in crates/media/src/lib.rs:
weft::contract!(); // types and a `Service` trait, generated from your Zod: never hand-writtenuse contract::*;struct Media;impl Service for Media { async fn digest(&self, _ctx: weft::Ctx, input: DigestInput) -> Result<DigestOutput, weft::Error> { let hash = input.text.bytes().fold(0x811c9dc5u32, |h, b| (h ^ b as u32).wrapping_mul(0x01000193)); Ok(DigestOutput { bytes: input.text.len() as i64, hash: format!("{hash:08x}") }) }}weft::native!(contract::functions(Media));A component calls it like any query, digest({ text }), and never learns it's Rust.
One contract, checked on both sides
You never write the Rust types. weft check, weft dev and weft build write the server functions' schemas to .weft/contract.json, and the crate's build.rs turns that into Rust: a struct for each input and output, an error enum for each function with typed errors, and one Service trait with a method per function. A method is the function's name in snake case; a type is its name plus Input or Output.
So when you change the Zod, the Rust stops compiling exactly where it no longer matches. weft check runs cargo check and reports that as R103, with the function's id. It's the same safety a TypeScript run gets from its types, across the language boundary.
Not every Zod type can cross, because the wire is JSON:
z.date()is R101. Usez.iso.datetime(), or a codec to aDateon the TypeScript side.z.void()is R101. Usez.null().- A one-way
.transform()on an input is R101, since it can't be encoded back..trim(),.default()and codecs are fine. - A plain
z.unionof objects is R102. Tag it withz.discriminatedUnion.
What happens where
TypeScript keeps the public edge: sessions, CSRF, input validation, the cache, auth, tags and invalidates. The Rust gets one crossing per call with the parsed input, the user's claims (never their cookies), a trace id and a deadline. TypeScript then validates the output, in production too.
Inside, ctx depends on the function. With auth: 'user' it's a weft::UserCtx, where ctx.user() is always there. A query cached for everyone gets a weft::SharedCtx, which has no user() at all, so a user's data can't leak into a shared cache. Every kind has ctx.touch(table) to report a table it read or wrote, ctx.tag(tag) to add a cache tag, ctx.remaining() and ctx.cancelled() for the deadline, and ctx.request_id().
A typed error crosses as it does from TypeScript: declare errors on the function, return the variant in Rust, and the client catches e.code and e.data. A panic or a weft::Error is R108: the client gets a request id, and the detail stays in the server's log.
Dev, build, deploy and tests
weft devbuilds each crate and rebuilds on an edit. A sidecar's new binary takes over once it's healthy, so no request fails; anative()crate's next call loads the new build. A failed build keeps the old one running.weft buildrunscargo build --release. Anative()crate's addon and.wasmgo into.output/server/rust/, so whatever moves the server moves them. Sidecars go in.output/rust/, andweft startruns and supervises them.native()needs a file system, so on an edge preset it's R104: useremote()there, with the same crate.- In a test,
net.handle(fn, run)replaces a Rust body as it does arun, and nothing crosses. You don't needcargoto test the code that calls it. See Testing.
The Rust reference has the rest: every option, the deployment settings, and R101 to R109.
Recap
- Rust is optional everywhere. The compiler is written in it and reaches you as a prebuilt addon or WebAssembly, with the same output.
weft doctorsays which. - The compiler parses each file once with Oxc, analyzes it (the W-codes), transforms props and lists, emits templates and bindings, and records facts that
weft checkjoins across files. npm run build:nativeinpackages/weftis for working on weft itself.- Write a server function in Rust at about a millisecond of CPU per call.
native()runs it in process;remote()runs a sidecar.weft add rustsets either up. - The Zod schema is the contract. The Rust types are generated from it, so a schema change stops
cargowhere the Rust no longer matches (R103).
Try it
Challenge 1
Rust or not?
For each function, would you write it in TypeScript, native() or remote()? (a) listOrders, a database read with a join. (b) resizeImage, decoding and resizing a 4 MB photo. (c) searchDocs, ranking over an index the process keeps in memory and reloads every hour.
Show the solution
(a) TypeScript: the time goes to the database, and a crossing would only add to it. (b) native(): one hot CPU function on bytes, and an in-process call costs microseconds. (c) remote(): it has its own long-lived state and a reload loop, which is what a sidecar is for, and a sidecar can be restarted without the server.
Challenge 2
Make it cross
weft check reports R101 on this function. Fix the schema so it can have a Rust body.
<!-- example: skip deliberately reports R101, which the challenge asks you to fix -->
// src/server/events.tsimport { query } from '@weft-js/start'import { z } from 'zod'import { media } from './rust'export const nextEvent = query({ input: z.object({ after: z.string() }), output: z.object({ title: z.string(), starts: z.date() }), impl: media,})Show the solution
// src/server/events.tsimport { query } from '@weft-js/start'import { z } from 'zod'import { media } from './rust'export const nextEvent = query({ input: z.object({ after: z.iso.datetime() }), output: z.object({ title: z.string(), starts: z.iso.datetime() }), impl: media,})JSON has no date type, so a Date can't cross. An ISO string can, and Rust parses it with whatever date crate you use. If the TypeScript side wants a Date, a z.codec from z.iso.datetime() to z.date() gives it one after the output is validated.
Challenge 3
Write the Rust
You add this function next to digest. Write its method in impl Service for Media.
// src/server/words.tsimport { query } from '@weft-js/start'import { z } from 'zod'import { media } from './rust'export const countWords = query({ input: z.object({ text: z.string() }), output: z.object({ words: z.int() }), impl: media,})Show the solution
async fn count_words(&self, _ctx: weft::Ctx, input: CountWordsInput) -> Result<CountWordsOutput, weft::Error> { Ok(CountWordsOutput { words: input.text.split_whitespace().count() as i64 })}The method is the function's name in snake case, and the types are its name plus Input and Output, all generated from the Zod. Until you add the method, cargo check (and so weft check) fails with R103, naming countWords.