Skip to content

feat(WebClient): add Node.js support#1908

Open
JereSalo wants to merge 137 commits intonextfrom
jere/webclient-generic-clean
Open

feat(WebClient): add Node.js support#1908
JereSalo wants to merge 137 commits intonextfrom
jere/webclient-generic-clean

Conversation

@JereSalo
Copy link
Copy Markdown
Collaborator

@JereSalo JereSalo commented Mar 16, 2026

Adds a Node.js build of the web client using napi-rs. The same Rust crate compiles to both wasm32 (browser, via wasm-bindgen) and native (Node.js, via napi-rs) using mutually exclusive feature flags (browser vs nodejs).

#[js_export] macro

A new proc macro replaces #[wasm_bindgen] on exported items. It expands to #[cfg_attr(feature = "browser", wasm_bindgen)] + #[cfg_attr(feature = "nodejs", napi)]. When a method uses JsU64 in its signature (u64/BigInt on browser, f64/number on Node.js), the macro splits it into two platform-specific impl blocks automatically.

A platform.rs module provides type aliases that switch per feature (JsErr, JsBytes, AsyncCell, ClientAuth, etc.), letting most methods be written once. Only create_client, create_mock_client, and settings need separate implementations due to fundamental platform differences (IndexedDB vs SQLite, WebKeyStore vs FilesystemKeyStore).

Note on the macro: We could theoretically remove the macro but it might be worth keeping it in order to reduce the amount of lines and boilerplate we have in the rest of the code. It's just a tradeoff that we might want, but in case there's a strong reason for removing it we could do it.

Single npm package with conditional exports

The same @miden-sdk/miden-sdk package now works in both browser and Node.js. Users write the same import in both environments:

import { MidenClient } from "@miden-sdk/miden-sdk";
const client = await MidenClient.create({ rpcUrl: "testnet" });

The package.json uses conditional exports ("browser" and "node" conditions) so the runtime or bundler automatically selects the right entry point. The browser entry (dist/index.js) loads WASM + IndexedDB. The Node.js entry (js/node-index.js) loads the napi native binary + SQLite. The MidenClient class and all resource classes (accounts, transactions, notes, etc.) are shared between both backends.

A Node.js normalization layer (js/node/) handles API differences between napi and wasm-bindgen: BigInt to Number conversion, null to undefined for Option types, snake_case method aliases, and array type polyfills.

Platform binary distribution

Native Node.js binaries are distributed via platform-specific npm packages (@miden-sdk/node-darwin-arm64, @miden-sdk/node-darwin-x64, @miden-sdk/node-linux-x64-gnu). These are listed as optionalDependencies (injected at publish time) so npm installs only the matching platform. A loader with fallback to the local target/ directory supports development without prebuilt binaries.

Note: We inject optional dependencies at publish time because if they were in the package.json then yarn install would fail because it tries to get those packages from npm and they don't exist yet.

Publishing changes

The existing publish workflows (publish-web-client-next.yml and publish-web-client-release.yml) now include a matrix build step that compiles native binaries on 3 platforms (macOS ARM, macOS x64, Linux x64) and publishes the platform packages before the main package. The js-export-macro crate is enabled for publishing to crates.io as users need it to compile miden-client-web.

Platform-agnostic tests

20 test files were rewritten to a platform-agnostic format using a run fixture that abstracts the runtime:

test("creates a wallet", async ({ run }) => {
  const result = await run(async ({ client, sdk }) => {
    const wallet = await client.newWallet(
      sdk.AccountStorageMode.private(), true, sdk.AuthScheme.AuthRpoFalcon512
    );
    return { id: wallet.id().toString() };
  });
  expect(result.id).toMatch(/^0x/);
});

On Node.js, run calls the callback directly with a napi client (SQLite store). On browser, it serializes the callback and executes it inside a page via page.evaluate() (WASM + IndexedDB). A normalization layer handles API differences between platforms (null to undefined, BigInt to Number, method name aliases).

7 browser-only test files (store_isolation, sync_lock, import_export, etc.) are skipped on Node.js.

Additional Notes

  • Maintained WASM test coverage despite the test migration
  • Tested release and publish flow in my personal fork so that we know it's going to work. Also, WASM is still published even if the NodeJS part fails for some reason.

Closes #1667

juan518munoz and others added 30 commits February 18, 2026 15:28
Access the keystore through Client's authenticator instead of storing it
separately. Add authenticator() and store() getters to Client<AUTH>,
refactor setup_client to accept store/keystore/rng as parameters, and
extract create_rng helper for the seed-to-RpoRandomCoin logic.
The slotName→root difference is a base branch issue (next hasn't been
merged into jmunoz-decouple-store-from-web yet). Reverting to keep our
diff focused on the store generalization.
Move wrap_send to platform.rs as maybe_wrap_send (no-op on browser,
AssertSend wrapper on nodejs), unify get_mut_inner() and keystore()
into shared impl blocks, and extract build_error_chain from the
duplicated js_error_with_context implementations.
…unctions

Store export/import is not the client's responsibility. The store field
(cfg-gated as Arc<dyn WebStore> for browser and Arc<dyn Store> for nodejs)
was only needed for exportStore/forceImportStore methods and was
stored-but-never-read on nodejs.

exportStore and forceImportStore are now standalone wasm_bindgen functions
that take a store_name parameter, keeping the functionality available
without coupling it to the client. Tests updated accordingly.
debug_mode was only consumed once during setup_client and required a
two-step setDebugMode() + createClient() pattern. Now it's an optional
parameter on createClient directly, simplifying the struct.
Use Vec::from() with the existing From trait impls on array types
instead of directly accessing the browser-specific __inner field.
This eliminates ~20 cfg blocks across model files.

Also unify settings remove/list methods, serialize_to_bytes, and
bytes_to_js to work identically on both platforms.

Fix rollup.config.js to include the browser feature alongside testing.
Five functions changed from sync to async in the wasm_bindgen -> js_export
migration: newMintTransactionRequest, newSendTransactionRequest,
newSwapTransactionRequest, createCodeBuilder, and accountReader.
All test call sites now properly await these promises.
…ter name

- Rpo256::hash_elements and AdviceMap::insert: change FeltArray param back
  to &FeltArray (borrow) to match next branch behavior. By-value params
  consume the JS object, causing null pointer when reused.
- FetchedNote::get_note getter: add js_name = "note" so the JS property
  is "note" instead of "get_note" (on next, the method was fn note()).
…c-clean

# Conflicts:
#	Cargo.lock
#	crates/idxdb-store/src/lib.rs
#	crates/rust-client/src/lib.rs
#	crates/web-client/Cargo.toml
#	crates/web-client/js/client.js
#	crates/web-client/js/types/api-types.d.ts
#	crates/web-client/src/account.rs
#	crates/web-client/src/export.rs
#	crates/web-client/src/import.rs
#	crates/web-client/src/lib.rs
#	crates/web-client/src/mock.rs
#	crates/web-client/src/models/account_component.rs
#	crates/web-client/src/models/account_reader.rs
#	crates/web-client/src/models/account_storage_requirements.rs
#	crates/web-client/src/models/auth_secret_key.rs
#	crates/web-client/src/models/components/auth_falcon512_rpo_multisig.rs
#	crates/web-client/src/models/storage_map.rs
#	crates/web-client/src/new_account.rs
#	crates/web-client/src/rpc_client/mod.rs
#	crates/web-client/src/sync.rs
#	crates/web-client/test/import_export.test.ts
#	crates/web-client/test/miden_client_api.test.ts
#	crates/web-client/test/new_transactions.test.ts
#	crates/web-client/yarn.lock
- Add createMockClient for Node.js (SqliteStore + FilesystemKeyStore)
- Change mock_rpc_api/mock_note_transport_api fields to AsyncCell for &self access
- Add Drop impl to prevent Tokio runtime panic on Node.js exit
- Add node-adapter.ts: fake page, window.* globals, type normalization
- Modify playwright setup to support nodejs project alongside browser
- Add nodejs project to playwright config
- Add Express demo API server (examples/node-demo/)
- Add vitest-based test files (test/node/) and shared fixtures (test/shared/)
- 148/190 existing browser tests pass on Node.js with zero test changes
Wraps AccountBuilder, AccountComponent, AuthSecretKey with arg
normalization for static methods. Deduplicates getWasmOrThrow.
Node.js tests: 160/190 passing (up from 148).
@JereSalo JereSalo force-pushed the jere/webclient-generic-clean branch from 04e8ea0 to caf38ec Compare April 6, 2026 19:26
@JereSalo JereSalo marked this pull request as ready for review April 8, 2026 18:43
@JereSalo
Copy link
Copy Markdown
Collaborator Author

Note that there may be some NodeJS specific things to improve, but if possible we should try to focus the review on the broader changes that may have an impact in the existing codebase. Any details of NodeJS can be addressed in follow-ups if they are not important. I just leave this comment because the PR is incredibly large and it's best to address the most relevant things now and if it's mergeable we can go for it and leave the rest of the points to tackle in a follow-up issue.

Copy link
Copy Markdown
Contributor

@lima-limon-inc lima-limon-inc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the js_export macro idea! It's a shame that it has to be shipped separately :(.

AFAIK, this is a pain point that other projects also encounter (stadiamaps/ferrostar#289 comes to mind).

I left a couple of comments, mainly regarding readability.

Comment on lines +92 to +96
if has_jsu64(&method) {
// Tag the method with its js_export args for later.
method.attrs.push(syn::parse_quote!(#[__js_export_args(#method_attr_tokens)]));
jsu64_methods.push(method);
} else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are checking if the method has U64? Could we leave a comment explaining the rationale?

Edit: Found out why a bit below, maybe it'd be worth pointing to the make_platform_method in a comment

Edit 2: I also read that it's on top level doc!

Comment on lines +85 to +86
for member in item.items.drain(..) {
match member {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe to increase readability a bit I would a couple of comments stating what we're doing.
Mainly because syn's API can be a bit unintuitive from time to time.

let output = match item {
Item::Struct(s) => handle_struct(&attr, s),
Item::Enum(e) => handle_enum(&attr, e),
Item::Impl(i) => handle_impl(&attr, i),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the context of js bindings, functions can only be methods of structs, correct?

If that's the case, maybe I'd add a comment with a link to the docs. So that code that reflects this (e.g. [methods](So things like https://github.com/0xMiden/miden-client/pull/1908/changes#diff-2d13f93b3c68ba298247a3786c2780f5aea5b651ff05948645a918d6f8695b30R87
)) are also explained in the code.

}

/// Extract the `#[__js_export_args(...)]` marker we stashed earlier.
fn extract_stashed_args(method: &mut ImplItemFn) -> TokenStream2 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'm not sure I understand the rationale behind fn extract_stashed_args(method: &mut ImplItemFn) -> TokenStream2 {

It seems to me that we are adding the __js_export_args argument on line 95, just so we can remove it later on line 169, inside the make_platform_method.

Can't this flow be unified on one place?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer PRs that come from internal contributors or integration partners. They should be given priority. no changelog This PR does not require an entry in the `CHANGELOG.md` file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add node.js support to the web client

7 participants