Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
416 changes: 413 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
resolver = "2"
members = [
"from_js_ref",
"tests/wasm_error_tests",
"tests/wasm_trait_tests",
"wasm_refgen",
"wasm_bindgen_error",
"wasm_bindgen_trait",
"wasm_refgen",
]

[workspace.package]
Expand Down
84 changes: 75 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

Utilities for working with Rust-exported `wasm-bindgen` types in JS environments.

Solves the problem of using Rust types across the Wasm boundary where
`wasm-bindgen` imposes limitations (no generics, consuming ownership,
no references in `Vec`s, no trait impls, stringly-typed errors, etc.).

Check failure on line 7 in README.md

View workflow job for this annotation

GitHub Actions / spellcheck

Misspelled word

Misspelled word "stringly-typed". Suggested alternatives: "strongly-typed", "stringy-typed", "strikingly-typed", "stirringly-typed", "stringently-typed", "string-typed", "stringiness" If you want to ignore this message, add stringly-typed to the ignore file at .github/workflows/dictionary.txt

Check failure on line 7 in README.md

View workflow job for this annotation

GitHub Actions / spellcheck

Misspelled word

Misspelled word "impls". Suggested alternatives: "impl", "imps", "impels", "limps", "imply", "imp's", "imp ls", "imp-ls", "impl s" If you want to ignore this message, add impls to the ignore file at .github/workflows/dictionary.txt

## Crates

| Crate | Version | Description |
|--------------------------------|---------|--------------------------------------------------------------------------------------------------|
| [`wasm_refgen`](./wasm_refgen) | 0.2.0 | Proc-macro that generates duck-typed JS reference boilerplate for `wasm-bindgen` structs |
| [`from_js_ref`](./from_js_ref) | 0.2.0 | Runtime traits (`FromJsRef`, `JsDeref`) for converting between JS reference types and Rust types |
| Crate | Version | Description |
|----------------------------------------------|---------|--------------------------------------------------------------------------------------------------|
| [`wasm_refgen`](./wasm_refgen) | 0.2.0 | Proc-macro that generates duck-typed JS reference boilerplate for `wasm-bindgen` structs |
| [`from_js_ref`](./from_js_ref) | 0.2.0 | Runtime traits (`FromJsRef`, `JsDeref`) for converting between JS reference types and Rust types |
| [`wasm_bindgen_trait`](./wasm_bindgen_trait) | 0.1.0 | JS duck-typed interfaces as Rust traits with compile-time conformance checking |
| [`wasm_bindgen_error`](./wasm_bindgen_error) | 0.1.0 | Derive macro to convert Rust error types into named JS errors |

## Quick Start

```toml
[dependencies]
from_js_ref = "0.2.0"
wasm_refgen = "0.2.0"
```
### Reference types with `wasm_refgen`

Generate a JS reference type so your exported struct can appear in
`Vec`s, generics, and function signatures:

```rust
use wasm_bindgen::prelude::*;
Expand Down Expand Up @@ -62,6 +67,67 @@

See the [`wasm_refgen` README](./wasm_refgen/README.md) for detailed documentation.

### JS interfaces with `wasm_bindgen_trait`

Define a JS duck-typed interface as a Rust trait, then verify your
exported struct conforms at compile time:

```rust
use wasm_bindgen::prelude::*;
use wasm_bindgen_trait::{js_trait, wasm_implements};

// Define the JS interface
#[js_trait(js_type = JsStorage)]
pub trait Storage {
#[wasm_bindgen(js_name = "save")]
async fn js_save(&self, key: String, value: JsValue) -> Result<(), JsValue>;

#[wasm_bindgen(js_name = "load")]
async fn js_load(&self, key: String) -> Result<JsValue, JsValue>;
}

// Export a Rust implementation that conforms to the interface
#[wasm_bindgen]
pub struct WasmMemoryStorage { /* ... */ }

#[wasm_implements(Storage)]
#[wasm_bindgen(js_class = "WasmMemoryStorage")]
impl WasmMemoryStorage {
#[wasm_bindgen(js_name = "save")]
pub async fn js_save(&self, key: String, value: JsValue) -> Result<(), JsValue> {
Ok(())
}

#[wasm_bindgen(js_name = "load")]
pub async fn js_load(&self, key: String) -> Result<JsValue, JsValue> {
Ok(JsValue::UNDEFINED)
}
}
```

See the [`wasm_bindgen_trait` README](./wasm_bindgen_trait/README.md) for detailed documentation.

### Named JS errors with `wasm_bindgen_error`

Convert Rust error types into JS `Error` objects with meaningful `.name`
properties:

```rust
use thiserror::Error;
use wasm_bindgen_error::WasmError;

#[derive(Debug, Error)]
#[error("document not found: {id}")]
pub struct NotFoundError { id: String }

// JS sees: { name: "NotFoundError", message: "document not found: abc" }
#[derive(Debug, Error, WasmError)]
#[error(transparent)]
pub struct WasmNotFoundError(#[from] NotFoundError);
```

See the [`wasm_bindgen_error` README](./wasm_bindgen_error/README.md) for detailed documentation.

## License

Apache-2.0
20 changes: 10 additions & 10 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
description = "wasm_utils";

inputs = {
nixpkgs.url = "nixpkgs/nixos-25.11";
nixpkgs.url = "nixpkgs/nixos-26.05";
nixpkgs-unstable.url = "nixpkgs/nixpkgs-unstable";

command-utils.url = "git+https://codeberg.org/expede/nix-command-utils";
Expand Down Expand Up @@ -118,18 +118,18 @@
devShells.default = pkgs.mkShell {
name = "wasm_utils shell";

nativeBuildInputs = with pkgs;
[
command_menu
nativeBuildInputs =
command_menu
++ [
nightly-rustfmt
rust-toolchain

http-server
pkgs.binaryen
pkgs.nodePackages_latest.webpack-cli
pkgs.http-server
pkgs.nodejs_22
pkgs.rust-analyzer
pkgs.wasm-pack
pkgs.webpack-cli
]
++ format-pkgs
++ cargo-installs;
Expand Down
3 changes: 3 additions & 0 deletions from_js_ref/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ wasm-bindgen.workspace = true
[dev-dependencies]
wasm_refgen = { path = "../wasm_refgen" }

[lints]
workspace = true

[features]
default = []
std = []
36 changes: 33 additions & 3 deletions from_js_ref/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,52 @@

use wasm_bindgen::{JsCast, JsValue};

/// A trait for converting from a JS reference type to a Rust type.
/// Convert from a JS-imported reference type to a Rust-exported type.
///
/// Implement this trait to enable conversion from a typed JS reference
/// wrapper (generated by `#[wasm_refgen]`) back to the concrete Rust type.
///
/// The default [`try_from_js_value`](FromJsRef::try_from_js_value)
/// implementation uses `dyn_ref` (i.e. `instanceof`). When using
/// `#[wasm_refgen]`, the generated impl overrides this with a duck-type
/// check via `Reflect::has` — prefer `try_from_js_value` over `dyn_into`
/// for `wasm_refgen`-generated types.
pub trait FromJsRef: Sized {
/// The JS-imported reference type (e.g., `JsFoo` generated by `#[wasm_refgen]`).
type JsRef: wasm_bindgen::JsCast;

/// Converts from a JS reference type to the Rust type.
/// Infallible conversion from a typed JS reference to the Rust type.
fn from_js_ref(castable: &Self::JsRef) -> Self;

/// Attempts to convert from a `JsValue` to the Rust type by going through the JS reference type.
/// Fallible conversion from a raw `JsValue`.
///
/// Returns `Some` if the value is the expected type, `None` otherwise.
///
/// The default implementation uses `dyn_ref` (`instanceof`), which does
/// _not_ work for `wasm_refgen`-generated extern types. The generated
/// `FromJsRef` impl overrides this with a duck-type `Reflect::has` check.
#[must_use]
fn try_from_js_value(js_value: &JsValue) -> Option<Self> {
js_value
.dyn_ref::<Self::JsRef>()
.map(|js_ref| Self::from_js_ref(js_ref))
}
}

/// Convenience trait for converting a JS reference to its Rust counterpart.
///
/// This is a blanket-implemented companion to [`FromJsRef`]. Instead of
/// calling `WasmFoo::from_js_ref(&js_foo)`, you can call `js_foo.js_deref()`:
///
/// ```ignore
/// use from_js_ref::JsDeref;
///
/// let foo: WasmFoo = js_foo.js_deref();
/// ```
///
/// Automatically available on any `T::JsRef` where `T: FromJsRef`.
pub trait JsDeref<T: FromJsRef> {
/// Convert this JS reference to the corresponding Rust type.
fn js_deref(&self) -> T;
}

Expand Down
8 changes: 8 additions & 0 deletions from_js_ref/tests/wasm_refgen.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
#![allow(
clippy::missing_const_for_fn,
clippy::must_use_candidate,
missing_copy_implementations,
missing_debug_implementations,
missing_docs
)]

use from_js_ref::FromJsRef as _;
use wasm_bindgen::prelude::*;
use wasm_refgen::wasm_refgen;
Expand Down
11 changes: 11 additions & 0 deletions tests/wasm_error_tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "wasm_error_tests"
version = "0.0.0"
edition.workspace = true
publish = false

[dependencies]
js-sys.workspace = true
thiserror = "2"
wasm-bindgen.workspace = true
wasm_bindgen_error = { path = "../../wasm_bindgen_error" }
78 changes: 78 additions & 0 deletions tests/wasm_error_tests/src/doc_examples.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! Compilable versions of the `wasm_bindgen_error` crate-level doc examples.
//!
//! These live here instead of as doc-tests because `wasm_bindgen_error` is a
//! proc-macro crate. Proc-macro crates can only export macros — their
//! `Cargo.toml` dependencies (`proc-macro2`, `quote`, `syn`) are for the
//! macro implementation, not available to doc-test compilation. Any doc
//! example referencing `wasm_bindgen::JsValue` or `js_sys::Error` would
//! fail with "unresolved module." This integration test crate has those
//! dependencies, so the same code compiles here.
//!
//! The source doc comments keep `ignore` markers so the examples still
//! render in `rustdoc`.

use thiserror::Error;
use wasm_bindgen::prelude::*;
use wasm_bindgen_error::WasmError;

// ── Doc example: "Motivation" (line 14) ─────────────────────────────
// The hand-written boilerplate that WasmError replaces.

#[derive(Debug, Error)]
#[error("manual error")]
pub struct ManualError;

impl From<ManualError> for JsValue {
fn from(err: ManualError) -> Self {
let js_err = js_sys::Error::new(&err.to_string());
js_err.set_name("ManualError");
js_err.into()
}
}

#[test]
fn manual_from_impl_compiles() {
fn assert_into_jsvalue<T: Into<JsValue>>() {}
assert_into_jsvalue::<ManualError>();
}

// ── Doc example: "Usage" (line 28) ──────────────────────────────────
// The derive macro in action.

#[derive(Debug, Error)]
#[error("hydration failed")]
pub struct HydrationError;

#[derive(Debug, Error, WasmError)]
#[error(transparent)]
pub struct WasmHydrationError(#[from] HydrationError);

#[derive(Debug, Error)]
#[error("io error")]
pub struct IoError;

#[derive(Debug, Error, WasmError)]
#[wasm_error(js_name = "StorageFailure")]
#[error(transparent)]
pub struct WasmIoError(#[from] IoError);

#[test]
fn derive_usage_compiles() {
fn assert_into_jsvalue<T: Into<JsValue>>() {}
assert_into_jsvalue::<WasmHydrationError>();
assert_into_jsvalue::<WasmIoError>();
}

#[test]
fn question_mark_chains() {
fn inner() -> Result<(), HydrationError> {
Err(HydrationError)
}

fn outer() -> Result<(), WasmHydrationError> {
inner()?;
Ok(())
}

assert!(outer().is_err());
}
22 changes: 22 additions & 0 deletions tests/wasm_error_tests/src/js_name_override.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Explicit `js_name` override — when the default prefix stripping
//! doesn't produce the right JS error name.

use thiserror::Error;
use wasm_bindgen_error::WasmError;

/// The Rust name doesn't have a "Wasm" prefix, and we want a
/// specific JS error name that differs from the Rust type name.
#[derive(Debug, Error, WasmError)]
#[wasm_error(js_name = "StorageFailure")]
#[error("I/O error: {reason}")]
pub struct JsStorageError {
reason: &'static str,
}

/// Without the override, this would be "JsStorageError" on the JS side.
/// With it, JS sees "StorageFailure".
#[test]
fn override_compiles() {
fn assert_from<T: Into<wasm_bindgen::JsValue>>() {}
assert_from::<JsStorageError>();
}
Loading
Loading