Project Code:https://github.com/euv-dev/euv
euv is a Rust + WebAssembly frontend UI framework that compiles Rust code into WASM modules running in the browser. While the core UI logic is written in Rust, real-world projects often need to interoperate with the vast TypeScript/JavaScript ecosystem — from calling JavaScript libraries to exposing Rust functions to TypeScript code. This article explores how euv leverages wasm-bindgen, js-sys, and web-sys to enable seamless interoperability between Rust and TypeScript.
Table of Contents
- Understanding the WASM Interop Layer
- Calling JavaScript from Rust with js-sys
- Accessing Browser APIs with web-sys
- Exposing Rust Functions to TypeScript
- Working with JavaScript Promises and Async
- Practical Patterns for TS Interop
- Conclusion
Understanding the WASM Interop Layer
When euv compiles to WebAssembly, the resulting .wasm module communicates with JavaScript through a thin glue layer generated by wasm-bindgen. This tool automatically generates JavaScript wrapper functions that handle the marshaling of types across the Rust-JavaScript boundary.
The key crates involved in this interop are:
-
wasm-bindgen — Generates the JS glue code and provides the
#[wasm_bindgen]attribute for exposing Rust functions to JavaScript -
js-sys — Provides raw bindings to JavaScript's standard built-in objects (
Object,Array,Promise,JSON,Math, etc.) -
web-sys — Provides raw bindings to the browser's Web APIs (
Window,Document,Storage,fetch, etc.)
In euv, all types from js-sys and web-sys are re-exported through use euv::*, so you never need to add these crates as direct dependencies. The framework handles the interop plumbing for you.
Calling JavaScript from Rust with js-sys
The js-sys crate provides bindings to JavaScript's core built-in objects. Through euv's re-exports, you can access these directly.
Working with JavaScript Objects
use euv::*;
// Create a JavaScript object
let obj: Object = Object::new();
// Set properties on the object
Reflect::set(&obj, &"name".into(), &"euv".into()).unwrap();
// Get properties from the object
let name: JsValue = Reflect::get(&obj, &"name".into()).unwrap();
let name_string: String = name.as_string().unwrap_or_default();
Working with JavaScript Arrays
use euv::*;
// Create a JavaScript array
let arr: Array = Array::new();
// Push values to the array
arr.push(&"Rust".into());
arr.push(&"WASM".into());
arr.push(&"euv".into());
// Iterate over the array
arr.for_each(&mut |value: JsValue, _index: u32, _array: &Array| {
web_sys::console::log_1(&value);
});
// Get array length
let length: u32 = arr.length();
Using JSON
use euv::*;
// Parse a JSON string
let json_string: String = r#"{"name": "euv", "version": "0.1"}"#.to_string();
let json_value: JsValue = JSON::parse(&json_string).unwrap();
// Stringify a JavaScript object
let obj: Object = Object::new();
Reflect::set(&obj, &"framework".into(), &"euv".into()).unwrap();
let stringified: String = JSON::stringify(&obj).unwrap().as_string().unwrap_or_default();
Using JavaScript Promises
use euv::*;
// Create a JavaScript promise that resolves after a delay
let promise: Promise = Promise::new(&mut |resolve: Function, _reject: Function| {
let window: Window = window().expect("no global window exists");
let closure: Closure<dyn Fn()> = Closure::new(move || {
let _ = resolve.call0(&JsValue::NULL);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
1000,
).expect("set_timeout failed");
closure.forget();
});
// Convert to JsFuture and await it
let future: JsFuture = JsFuture::from(promise);
match future.await {
Ok(value) => {
web_sys::console::log_1(&"Promise resolved!".into());
}
Err(err) => {
web_sys::console::log_1(&err);
}
}
Accessing Browser APIs with web-sys
The web-sys crate provides comprehensive bindings to browser APIs. euv re-exports these through use euv::* and pre-enables commonly used features in its Cargo.toml.
Window and Document Access
use euv::*;
let win: Window = window().expect("no global window exists");
let doc: Document = win.document().expect("should have a document");
Navigation and Routing
use euv::*;
let win: Window = window().expect("no global window exists");
let location: Location = win.location();
// Get current hash route
let hash: String = location.hash().unwrap_or_default();
// Set hash route (page navigation)
let _ = location.set_hash("#/about");
Local Storage
use euv::*;
let win: Window = window().expect("no global window exists");
let storage: Option<Storage> = win.local_storage().unwrap_or_default();
if let Some(storage) = storage {
// Write
let _ = storage.set_item("key", "value");
// Read
let value: Option<String> = storage.get_item("key").unwrap_or_default();
// Delete
let _ = storage.remove_item("key");
}
Making HTTP Requests with fetch
use euv::*;
let loading: Signal<bool> = use_signal(|| false);
let loading_updater: Signal<bool> = loading;
let data: Signal<String> = use_signal(|| "".to_string());
let data_updater: Signal<String> = data;
let error: Signal<String> = use_signal(|| "".to_string());
let error_updater: Signal<String> = error;
button {
onclick: move |_event: Event| {
loading_updater.set(true);
error_updater.set("".to_string());
let data_updater_clone: Signal<String> = data_updater;
let loading_updater_clone: Signal<bool> = loading_updater;
let error_updater_clone: Signal<String> = error_updater;
spawn_local(async move {
let window: Window = window().expect("no global window exists");
let promise: Promise = window.fetch_with_str("https://httpbin.org/get");
let future: JsFuture = JsFuture::from(promise);
match future.await {
Ok(response) => {
let resp: Response = response.dyn_into().unwrap();
let json_promise: Promise = resp.json().unwrap();
let json_future: JsFuture = JsFuture::from(json_promise);
match json_future.await {
Ok(json) => {
let json_string: String = JSON::stringify(&json).unwrap().as_string().unwrap_or_default();
data_updater_clone.set(json_string);
}
Err(_) => {
error_updater_clone.set("Failed to parse JSON".to_string());
}
}
}
Err(_) => {
error_updater_clone.set("Network request failed".to_string());
}
}
loading_updater_clone.set(false);
});
}
"Fetch Data"
}
In this example, spawn_local is euv's re-export of wasm_bindgen_futures::spawn_local, and JsFuture, JSON, Promise, Window, Response are all available through use euv::* without needing separate js-sys or web-sys dependencies.
Exposing Rust Functions to TypeScript
To make Rust functions callable from TypeScript, you use the #[wasm_bindgen] attribute. This generates the necessary JavaScript glue code.
Basic Function Export
use euv::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn main() {
console_error_panic_hook::set_once();
mount("#app", app);
}
The #[wasm_bindgen] attribute on the main function tells wasm-bindgen to generate a JavaScript wrapper. This function can then be called from TypeScript:
import init, { main } from './pkg/my_euv_app.js';
await init();
main();
Exporting Functions with Parameters
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
From TypeScript:
import { greet } from './pkg/my_euv_app.js';
const message = greet('World'); // "Hello, World!"
Exporting Functions that Return JsValue
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn get_config() -> JsValue {
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"name".into(), &"euv".into()).unwrap();
js_sys::Reflect::set(&obj, &"version".into(), &"0.1".into()).unwrap();
obj.into()
}
Working with JavaScript Promises and Async
euv's spawn_local function (re-exported from wasm_bindgen_futures::spawn_local) is the key to running asynchronous Rust code in the WASM environment. It allows you to spawn Rust futures that integrate seamlessly with the JavaScript event loop.
Fetching Data Asynchronously
use euv::*;
spawn_local(async move {
let window: Window = window().expect("no global window exists");
let promise: Promise = window.fetch_with_str("https://httpbin.org/get");
let future: JsFuture = JsFuture::from(promise);
match future.await {
Ok(response) => {
let resp: Response = response.dyn_into().unwrap();
let json_promise: Promise = resp.json().unwrap();
let json_future: JsFuture = JsFuture::from(json_promise);
match json_future.await {
Ok(json) => {
let json_string: String = JSON::stringify(&json).unwrap().as_string().unwrap_or_default();
data_updater_clone.set(json_string);
}
Err(_) => {
error_updater_clone.set("Failed to parse JSON".to_string());
}
}
}
Err(_) => {
error_updater_clone.set("Network request failed".to_string());
}
}
loading_updater_clone.set(false);
});
[!tip]
All browser APIs are accessed through
web-syscrate —euvpre-enables commonweb-sysfeatures (such asWindow,Document,Storage,Clipboard,Navigator,FileReader,FileList,IntersectionObserver, etc.) in itsCargo.toml, and re-exports them throughuse euv::*. You never need to manually addweb-sysas a dependency.
Using spawn_local with Signals
A common pattern is to use spawn_local to perform async operations and then update reactive signals when the operation completes:
let loading: Signal<bool> = use_signal(|| false);
let loading_updater: Signal<bool> = loading;
let data: Signal<String> = use_signal(|| "".to_string());
let data_updater: Signal<String> = data;
let error: Signal<String> = use_signal(|| "".to_string());
let error_updater: Signal<String> = error;
button {
onclick: move |_event: Event| {
loading_updater.set(true);
error_updater.set("".to_string());
let data_updater_clone: Signal<String> = data_updater;
let loading_updater_clone: Signal<bool> = loading_updater;
let error_updater_clone: Signal<String> = error_updater;
spawn_local(async move {
// ... async operations ...
data_updater_clone.set("result".to_string());
loading_updater_clone.set(false);
});
}
"Fetch Data"
}
[!warning]
Signal clones used in async closures must be cloned outside the closure and then moved in, otherwise borrow conflicts will occur.
Practical Patterns for TS Interop
Pattern 1: Calling JavaScript Libraries
When you need to use a JavaScript library (e.g., a charting library or date picker), you can create Rust bindings using #[wasm_bindgen] extern blocks:
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "some-js-library")]
extern "C" {
#[wasm_bindgen(js_name = "someFunction")]
pub fn some_function(input: &str) -> JsValue;
}
Then call it from Rust:
let result: JsValue = some_function("input");
let result_string: String = result.as_string().unwrap_or_default();
Pattern 2: Passing Callbacks to JavaScript
use wasm_bindgen::prelude::*;
let callback: Closure<dyn FnMut(JsValue)> = Closure::new(move |value: JsValue| {
let value_string: String = value.as_string().unwrap_or_default();
data_updater.set(value_string);
});
// Pass the callback to a JavaScript function
some_js_function(callback.as_ref().unchecked_ref());
// Important: forget the closure to prevent it from being dropped
// (only if you want it to persist beyond the current scope)
// callback.forget();
Pattern 3: Using web-sys with Event Handlers
use euv::*;
html! {
input {
r#type: "text"
placeholder: "Enter text"
oninput: move |event: Event| {
if let Some(target) = event.target()
&& let Ok(input) = target.clone().dyn_into::<HtmlInputElement>() {
name_signal.set(input.value());
}
}
}
}
In this example, dyn_into::<HtmlInputElement>() comes from wasm_bindgen::JsCast, which is re-exported through euv. The HtmlInputElement type is from web-sys, also re-exported through use euv::*.
Pattern 4: Combining Browser APIs with Reactive Signals
let tab: Signal<String> = use_signal(|| {
let win: Window = window().expect("no global window exists");
let location: Location = win.location();
let hash: String = location.hash().unwrap_or_default();
if hash == "#/settings" {
"settings".to_string()
} else {
"info".to_string()
}
});
html! {
div {
match { tab.get().as_str() } {
"info" => { div { "Information content" } }
_ => { div { "Settings content" } }
}
}
}
This pattern derives the initial signal value from the browser's URL hash, ensuring the UI state matches the URL on page load.
Pattern 5: Persisting State to Local Storage
let name: Signal<String> = use_signal(|| {
let win: Window = window().expect("no global window exists");
let storage: Option<Storage> = win.local_storage().unwrap_or_default();
if let Some(storage) = storage {
storage.get_item("user_name").unwrap_or_default()
} else {
"".to_string()
}
});
Conclusion
euv's TypeScript interoperability is built on the solid foundation of wasm-bindgen, js-sys, and web-sys. Through euv's re-export system (use euv::*), you get seamless access to:
-
JavaScript built-in objects (
Object,Array,Promise,JSON,Math, etc.) viajs-sys -
Browser APIs (
Window,Document,Location,Storage,fetch,Navigator, etc.) viaweb-sys -
Async operations via
spawn_local(re-exported fromwasm_bindgen_futures) -
Type casting via
JsCasttrait (dyn_ref,dyn_into) -
Function export via
#[wasm_bindgen]attribute
The key takeaway is that euv handles all the interop plumbing for you. You never need to add js-sys or web-sys as direct dependencies — everything is available through use euv::*. This allows you to focus on writing your application logic in Rust while still having full access to the JavaScript ecosystem when needed.
When building euv applications that need TypeScript interop, remember to:
- Use
#[wasm_bindgen]to expose Rust functions to JavaScript - Use
spawn_localfor async operations that need to interact with JavaScript APIs - Clone signals before moving them into async closures
- Use
dyn_refanddyn_intofor type-safe event handling - Leverage euv's re-exports to avoid managing
js-sysandweb-sysdependencies directly
Project Code:https://github.com/euv-dev/euv
Top comments (0)