Rust to WebAssembly skill for Codex
Coarse-grained Wasm modules that stay small. Codex first checks that Wasm fits the job, heavy compute in few calls rather than chatty DOM work, then sets up a cdylib crate with a narrow wasm-bindgen API that returns JsError instead of panicking. It picks the right wasm-pack target for your bundler, keeps the CLI and crate versions in lockstep, shrinks the output with release profile settings and wasm-opt, and tests both natively and in a headless browser.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-wasm Installs to ~/.agents/skills/rust-wasm/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-wasm The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-wasm Installs to .agents/skills/rust-wasm/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-wasm.zipunzip -o rust-wasm.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-wasm PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rust-wasm, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Compiles Rust to WebAssembly for browsers and Node with wasm-bindgen and wasm-pack - cdylib setup, wasm_bindgen attribute exports, js-sys and web-sys features, async with wasm-bindgen-futures, serde-wasm-bindgen for data, panic hooks, getrandom on wasm, size reduction with wasm-opt, and headless tests - plus when WASI targets fit instead. Use when moving Rust code into a web app, debugging wasm-bindgen version mismatches or unreachable panics, shrinking a .wasm bundle, or deciding whether Wasm is faster than plain JavaScript for a task.
Use it when
- Exposing a Rust library to browser or Node JavaScript
- wasm-bindgen version mismatches, getrandom or web-sys build errors
- Shrinking or profiling a .wasm bundle
- Deciding whether Wasm beats JavaScript for a workload
Use something else when
- You are running Rust at the edge on Cloudflare: use cloudflare-workers
- The blocker is borrow checker errors in the core crate: use rust-ownership
- Moving heavy JS work off the main thread is enough: use web-workers
How to check the result
cargo test and wasm-pack test pass, each export is called once from the consuming app including an error path, and the .wasm size is recorded before and after optimisation.
The mistake it prevents
Using a wasm-bindgen CLI whose version differs from the wasm-bindgen crate in Cargo.lock, which fails with a schema mismatch.
A good Rust/Wasm module has a narrow, coarse-grained JS interface (few calls, large
chunks of work per call), ships a small optimised .wasm, reports panics readably in
the console, and is tested both as Rust and through its generated JS bindings.
Decide first
| Goal | Fit |
|---|---|
| CPU-heavy work on large data (parsing, codecs, image/geometry, crypto, simulation) | good |
| Reusing an existing Rust library in the browser | good |
| DOM-heavy UI logic | poor: every DOM call crosses the boundary; keep it in JS or use a Rust UI framework knowingly |
| Many tiny calls per frame | poor: boundary and copying costs dominate |
| Server-side sandboxed plugins, CLI tools | WASI (wasm32-wasip1 / wasm32-wasip2) with Wasmtime, not wasm-bindgen |
Measure before committing: a plain JS implementation with typed arrays is often within 2x, and the Wasm version adds a build step and download size.
Setup
- Read
AGENTS.md, the crate’sCargo.toml, and the consuming JS project’s bundler (Vite, webpack, Next.js) andpackage.json. - Install the target:
rustup target add wasm32-unknown-unknown. Tools:cargo install wasm-pack(orwasm-bindgen-cli). These need network access; ask first. - Tooling status: the rustwasm GitHub org was archived in 2025. wasm-bindgen continues under its own organisation; wasm-pack moved to a community maintainer. Both work, but check the current repository and docs location before relying on old book links.
[package]
name = "imgcore"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"] # cdylib for wasm, rlib so native tests and benches work
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
console_error_panic_hook = { version = "0.1", optional = true }
[dependencies.web-sys]
version = "0.3"
features = ["console", "ImageData"] # each Web API type is opt-in
[dev-dependencies]
wasm-bindgen-test = "0.3"
[features]
default = ["console_error_panic_hook"]
[profile.release]
opt-level = "z" # or "s"; measure both, "3" if speed matters more than size
lto = true
codegen-units = 1
panic = "abort"
Exporting an API
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
#[derive(Deserialize)]
pub struct BlurOptions { radius: u32 }
#[derive(Serialize)]
pub struct Stats { min: u8, max: u8, mean: f32 }
/// Blurs RGBA pixels in place. One call per image, not per pixel.
#[wasm_bindgen]
pub fn blur(pixels: &mut [u8], width: u32, height: u32, opts: JsValue) -> Result<(), JsError> {
let opts: BlurOptions = serde_wasm_bindgen::from_value(opts)?;
if pixels.len() != (width * height * 4) as usize {
return Err(JsError::new("pixel buffer does not match width * height * 4"));
}
crate::core::blur_rgba(pixels, width, height, opts.radius);
Ok(())
}
#[wasm_bindgen]
pub fn stats(pixels: &[u8]) -> Result<JsValue, JsError> {
let s: Stats = crate::core::stats(pixels);
Ok(serde_wasm_bindgen::to_value(&s)?)
}
Boundary rules:
&[u8]/&mut [u8]map toUint8Array(copied in, and for&mutcopied back).Vec<u8>return values are copied out. For very large buffers, expose a pointer into Wasm memory only if profiling shows copies matter, and document that the view is invalidated when memory grows.- Return
Result<T, JsError>: errors become thrown JS exceptions with your message. Neverunwrap()on input from JS; a panic becomesRuntimeError: unreachable. - Exported structs (
#[wasm_bindgen] pub struct) live in Wasm memory; JS must call.free()(or useusing/ FinalizationRegistry support in recent wasm-bindgen). Prefer plain data via serde for results. - Keep the core logic in a plain Rust module with no wasm-bindgen types so it is testable natively and reusable.
Async and browser APIs
use wasm_bindgen::{prelude::*, JsCast};
use wasm_bindgen_futures::JsFuture;
use web_sys::Response;
#[wasm_bindgen]
pub async fn fetch_text(url: String) -> Result<String, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let resp: Response = JsFuture::from(window.fetch_with_str(&url)).await?.dyn_into()?;
let text = JsFuture::from(resp.text()?).await?;
Ok(text.as_string().unwrap_or_default())
}
Requires wasm-bindgen-futures and the Window and Response web-sys
features. async exports return a JS Promise. Threads need SharedArrayBuffer,
cross-origin isolation headers and a nightly toolchain; avoid unless essential.
Building
| Consumer | Command |
|---|---|
| Vite / webpack / bundler | wasm-pack build --release --target bundler |
| Plain ES modules in the browser, no bundler | wasm-pack build --release --target web then await init() |
| Node.js | wasm-pack build --release --target nodejs |
| Deno | wasm-pack build --release --target deno |
Without wasm-pack:
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/imgcore.wasm
wasm-opt -Oz -o pkg/imgcore_bg.opt.wasm pkg/imgcore_bg.wasm # binaryen
The wasm-bindgen CLI version must equal the wasm-bindgen crate version in
Cargo.lock exactly, or you get a schema mismatch error. Pin both, and install the CLI
with cargo install wasm-bindgen-cli --version <same>.
Vite needs vite-plugin-wasm (and often vite-plugin-top-level-await) for
--target bundler output, or use --target web and call init() yourself.
Common failures
| Symptom | Fix |
|---|---|
RuntimeError: unreachable | a panic; enable console_error_panic_hook to see the message, then replace the unwrap/index with an error |
getrandom fails to compile for wasm32 | enable getrandom’s JS backend: for 0.2 the js feature; for 0.3 the wasm_js feature and, depending on the 0.3 release, the getrandom_backend="wasm_js" cfg; follow the getrandom docs for the locked version |
| “it looks like the Rust project used to create this wasm file was linked against version X of wasm-bindgen” | CLI and crate versions differ; align them |
Huge .wasm (MBs) | release profile above, wasm-opt -Oz, drop format!-heavy code and large deps; inspect with twiggy top |
web_sys::X not found | add the feature for that type in Cargo.toml |
| Works in dev, fails after deploy | server must serve .wasm as application/wasm for streaming instantiation |
Testing
#[cfg(test)]
mod tests {
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn rejects_bad_buffer() {
let mut px = vec![0u8; 3];
assert!(super::blur(&mut px, 1, 1, wasm_bindgen::JsValue::NULL).is_err());
}
}
cargo test # core logic, native
wasm-pack test --headless --firefox # or --chrome; needs the browser + driver
wasm-pack test --node # without run_in_browser
Verification
-
cargo test(native) andwasm-pack test(Wasm) pass. -
cargo clippy --target wasm32-unknown-unknownis clean. - Record
.wasmsize before and after (raw and gzip/brotli):ls -l pkg/*.wasm. - A smoke test in the consuming app calls each export once, including an error path.
- Benchmark against the JS baseline if performance was the reason for Wasm.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Rust to WebAssembly"
short_description: "Coarse-grained Wasm modules that stay small"
default_prompt: "$rust-wasm Compile our Rust image library to WebAssembly and call it from the Vite app" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.