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
77 changes: 73 additions & 4 deletions .github/workflows/publish-web-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ jobs:
needs: check-version
if: needs.check-version.outputs.should_publish_web == 'true'
strategy:
# Independent legs: a musl cross-build failure must not cancel the
# glibc/darwin legs (which are the established, must-ship binaries).
fail-fast: false
matrix:
include:
- os: macos-26
Expand All @@ -107,7 +110,16 @@ jobs:
platform-package: node-sdk-darwin-x64
- os: ubuntu-24.04
platform-package: node-sdk-linux-x64-gnu
# musl / Alpine. Cross-compiled from the same ubuntu runner.
- os: ubuntu-24.04
platform-package: node-sdk-linux-x64-musl
rust-target: x86_64-unknown-linux-musl
runs-on: ${{ matrix.os }}
# The musl binary is newer/best-effort: a cross-build hiccup should leave
# this leg green so the publish job still ships the glibc + darwin
# binaries and the WASM package. The publish loop skips any missing
# artifact, so a failed musl leg simply defers musl to the next release.
continue-on-error: ${{ matrix.rust-target == 'x86_64-unknown-linux-musl' }}
Comment on lines +118 to +122

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don’t think the musl build should remain best-effort once the release advertises Alpine support. Right now, the release can still pass while silently omitting the musl artifact.

Could we bootstrap the npm package first and then make this matrix leg required?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I second this. Before, the Node SDK used to be experimental so we didn't want to interfere with the Web SDK, but now it'd make sense not to publish anything if any step fails, so that publishing is all-or-nothing.

steps:
- name: Check out code
uses: actions/checkout@v6
Expand All @@ -118,8 +130,36 @@ jobs:
run: rustup update --no-self-update

- name: Build napi binary
if: ${{ !matrix.rust-target }}
run: cargo build -p miden-client-web --no-default-features --features nodejs --release

- name: Build napi binary (musl cross via rust-musl-cross)
if: ${{ matrix.rust-target }}
# A dynamically linked musl cdylib so a musl-linked Node (Alpine) can
# dlopen it against its own libc instead of a second, statically
# embedded copy (-crt-static is disabled; a static CRT inside a shared
# object breaks dlopen).
#
# Build inside messense/rust-musl-cross, which ships a real
# musl-targeting GNU toolchain (with a musl libgcc). A plain cross-build
# with musl-tools' musl-gcc does not work here. musl-gcc only wraps the
# host glibc gcc and does not set rustc's linker, so rustc falls back to
# the host linker driver and silently produces a glibc-linked .so
# (NEEDED libc.so.6 / ld-linux-x86-64.so.2) that cannot load on Alpine.
# The rust-musl-cross toolchain links a genuine musl binary; the Alpine
# smoke test below is the backstop.
#
# The image is pinned by digest (not the mutable :x86_64-musl tag): this
# step runs in the provenance-signed publish pipeline, so the third-party
# toolchain that produces the shipped binary must be immutable. Bump the
# digest deliberately after re-verifying a new image.
run: |
docker run --rm \
-v "$PWD:/home/rust/src" -w /home/rust/src \
-e RUSTFLAGS="-C target-feature=-crt-static" \
messense/rust-musl-cross:x86_64-musl@sha256:ce75e9174325d4fbb3de85c309e2d7ca29f7500169bc4b5d2c611ff7e86d549a \
bash -c "rustup target add ${{ matrix.rust-target }} && cargo build -p miden-client-web --no-default-features --features nodejs --release --target ${{ matrix.rust-target }}"

- name: Prepare .node binary
run: |
if [ "${{ runner.os }}" = "macOS" ]; then
Expand All @@ -134,6 +174,20 @@ jobs:
fi
cp "$BINARY" "packages/${{ matrix.platform-package }}/miden_client_web.node"

- name: Verify musl binary loads on Alpine
if: ${{ matrix.rust-target }}
# A cross-compiled musl cdylib can link "successfully" yet fail to
# dlopen on a real musl runtime. Load the built addon inside
# node:alpine before publishing: if it can't load, this
# (continue-on-error) leg fails, the artifact is not uploaded, and the
# publish job skips musl — instead of shipping a broken binary that
# reintroduces the original "module not found" on Alpine.
run: |
docker run --rm \
-v "$PWD/packages/${{ matrix.platform-package }}:/pkg:ro" \
node:20-alpine \
node -e "require('/pkg/miden_client_web.node'); console.log('musl .node loaded on Alpine OK')"

- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
Expand Down Expand Up @@ -278,14 +332,28 @@ jobs:
run: |
WEB_VERSION=$(jq -r '.version' crates/web-client/package.json)
DIST_TAG="${{ needs.check-version.outputs.dist_tag }}"
for pkg in node-sdk-darwin-arm64 node-sdk-darwin-x64 node-sdk-linux-x64-gnu; do
for pkg in node-sdk-darwin-arm64 node-sdk-darwin-x64 node-sdk-linux-x64-gnu node-sdk-linux-x64-musl; do
if [ ! -f "artifacts/${pkg}/miden_client_web.node" ]; then
echo "::warning::No native binary artifact for ${pkg}; skipping its publish (its build leg produced none — see build-native-nodejs)."
continue
fi
cp "artifacts/${pkg}/miden_client_web.node" "packages/${pkg}/miden_client_web.node"
jq --arg v "$WEB_VERSION" '.version = $v' "packages/${pkg}/package.json" > /tmp/pkg.json
mv /tmp/pkg.json "packages/${pkg}/package.json"
echo "Publishing @miden-sdk/${pkg##node-sdk-}@${WEB_VERSION} (--tag ${DIST_TAG}) ..."
cd "packages/${pkg}"
npm publish --tag "$DIST_TAG" --access public --provenance
cd ../..
( cd "packages/${pkg}" && npm publish --tag "$DIST_TAG" --access public --provenance ) && continue
# Publish failed. musl is best-effort: it is the newest platform
# and its first-ever OIDC publish needs the trusted publisher for
# @miden-sdk/node-linux-x64-musl registered on npm (or a one-time
# manual publish). Defer it rather than wedging a release whose
# stable packages have already published. optionalDependencies
# tolerate a missing (404) version, so consumers are unaffected.
if [ "$pkg" = "node-sdk-linux-x64-musl" ]; then
echo "::warning::Publishing ${pkg} failed; deferring musl to the next release. First musl release? Register the trusted publisher for @miden-sdk/node-linux-x64-musl on npm (or publish it once manually)."
continue
fi
echo "::error::Publishing ${pkg} failed."
exit 1
done

- name: Inject Node.js optionalDependencies before web-client publish
Expand All @@ -299,6 +367,7 @@ jobs:
'@miden-sdk/node-darwin-arm64': v,
'@miden-sdk/node-darwin-x64': v,
'@miden-sdk/node-linux-x64-gnu': v,
'@miden-sdk/node-linux-x64-musl': v,
};
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log('Injected optionalDependencies with version ' + v);
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ packages/vite-plugin/node_modules/
!packages/node-sdk-darwin-x64/**
!packages/node-sdk-linux-x64-gnu/
!packages/node-sdk-linux-x64-gnu/**
!packages/node-sdk-linux-x64-musl/
!packages/node-sdk-linux-x64-musl/**
# Native binaries are built fresh in CI; never commit them.
packages/node-sdk-*/miden_client_web.node

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.15.6 (TBD)

### Fixes

* [FIX][web] The Node.js SDK now runs on **Alpine / musl Linux**. A new `@miden-sdk/node-linux-x64-musl` native binary is published and selected automatically by the runtime C library, so musl-based containers (a common ECS/Docker base image) no longer fail to import with `Miden napi module not found`; glibc hosts continue to use `@miden-sdk/node-linux-x64-gnu`. ([web-sdk#234](https://github.com/0xMiden/web-sdk/pull/234))
* [FIX][web] When the Node.js native addon can't be loaded, the thrown error now reports the real cause — platform, arch, libc, the package it looked for, and the underlying failure of each resolution step — plus targeted fixes, instead of a generic "build it with cargo" message. An explicit `MIDEN_MODULE_PATH` is now authoritative: a bad path fails immediately rather than silently falling back to a different installed binary. ([web-sdk#234](https://github.com/0xMiden/web-sdk/pull/234))

## 0.15.5 (2026-07-08)

### Enhancements
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A pnpm monorepo holding the JS / WASM / React bits previously part of [`0xMiden/
| `@miden-sdk/miden-sdk` | `crates/web-client/` (Rust + WASM + JS bindings) | npm |
| `@miden-sdk/react` | `packages/react-sdk/` | npm |
| `@miden-sdk/vite-plugin` | `packages/vite-plugin/` | npm |
| `@miden-sdk/node-{darwin-arm64,darwin-x64,linux-x64-gnu}` | `packages/node-sdk-*` | npm (platform-specific native binaries; consumed via `optionalDependencies` on `@miden-sdk/miden-sdk`) |
| `@miden-sdk/node-{darwin-arm64,darwin-x64,linux-x64-gnu,linux-x64-musl}` | `packages/node-sdk-*` | npm (platform-specific native binaries; consumed via `optionalDependencies` on `@miden-sdk/miden-sdk`. Linux ships both glibc (`-gnu`) and musl/Alpine (`-musl`) binaries; the loader picks by runtime libc, and the packages' `libc` field lets npm/pnpm install only the matching one) |
| `miden-idxdb-store` | `crates/idxdb-store/` | crates.io |

The `Cargo.toml` workspace dep `miden-client = "x.y.z"` pins compatibility with the upstream Rust crate. Changes to shared types (Account, Note, gRPC schema, …) usually need a coordinated PR in `0xMiden/miden-client` first.
Expand Down
170 changes: 151 additions & 19 deletions crates/web-client/js/node/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
*
* Search order:
* 1. MIDEN_MODULE_PATH environment variable (explicit override)
* 2. Platform-specific npm package (@miden-sdk/node-darwin-arm64, etc.)
* 2. Platform-specific npm package (@miden-sdk/node-darwin-arm64,
* @miden-sdk/node-linux-x64-gnu, @miden-sdk/node-linux-x64-musl, ...)
* 3. Package prebuilds directory
* 4. Repo target directory (for local development)
*
* On Linux the platform package is chosen by the runtime C library: an
* Alpine/musl host resolves the musl binary, a Debian/Ubuntu host the glibc
* binary. When resolution fails, the thrown error reports the platform, the
* package it looked for, and the real reason each step failed instead of a
* generic "not found".
*/
import { createRequire } from "module";
import path from "path";
Expand All @@ -15,6 +22,7 @@ import os from "os";
const require = createRequire(import.meta.url);

let _sdk = null;
let _libc = null;

/**
* Loads the napi SDK module. Caches the result after first load.
Expand All @@ -26,11 +34,24 @@ let _sdk = null;
export function loadNativeModule(options) {
if (_sdk) return _sdk;

// 1. Explicit path (option or env var)
// Each resolution step records why it failed, so the final error can
// report the real cause instead of a generic "not found".
const attempts = [];

// 1. Explicit path (option or env var). This is an authoritative override:
// if it is set but fails to load, surface that directly rather than
// silently falling back to a different (possibly stale) installed binary.
const explicit = options?.modulePath || process.env.MIDEN_MODULE_PATH;
if (explicit) {
_sdk = require(explicit);
return _sdk;
try {
_sdk = require(explicit);
return _sdk;
} catch (err) {
throw new Error(
`Miden napi module at MIDEN_MODULE_PATH="${explicit}" failed to ` +
`load: ${firstLine(err)}`
);
}
}

// 2. Platform-specific npm package (installed via optionalDependencies)
Expand All @@ -39,9 +60,11 @@ export function loadNativeModule(options) {
try {
_sdk = require(platformPackage);
return _sdk;
} catch {
// Not installed -- fall through to other methods
} catch (err) {
attempts.push(`require("${platformPackage}") -> ${firstLine(err)}`);
}
} else {
attempts.push(`no prebuilt package published for ${platformLabel()}`);
}

const archMap = { arm64: "aarch64", x64: "x86_64" };
Expand All @@ -66,8 +89,12 @@ export function loadNativeModule(options) {

for (const p of prebuildCandidates) {
if (fs.existsSync(p)) {
_sdk = require(p);
return _sdk;
try {
_sdk = require(p);
return _sdk;
} catch (err) {
attempts.push(`require("${p}") -> ${firstLine(err)}`);
}
}
}

Expand All @@ -91,31 +118,136 @@ export function loadNativeModule(options) {
) {
fs.copyFileSync(p, nodeFile);
}
_sdk = require(nodeFile);
return _sdk;
try {
_sdk = require(nodeFile);
return _sdk;
} catch (err) {
attempts.push(`require("${nodeFile}") -> ${firstLine(err)}`);
}
}
}
}

throw new Error(
`Miden napi module not found.\n\n` +
`Build it with:\n` +
` cargo build -p miden-client-web --no-default-features --features nodejs --release\n\n` +
`Or set MIDEN_MODULE_PATH to the .node file location.`
);
throw new Error(buildNotFoundMessage(platformPackage, attempts));
}

/**
* Returns the platform-specific npm package name for the current OS/arch,
* or null if the platform is not supported.
* or null if the platform has no published binary. On Linux the choice also
* depends on the runtime C library (glibc vs musl).
*/
function getPlatformPackageName() {
const key = `${os.platform()}-${os.arch()}`;
if (key === "linux-x64") {
return `@miden-sdk/node-linux-x64-${detectLibc().family}`;
}
const platformMap = {
"darwin-arm64": "@miden-sdk/node-darwin-arm64",
"darwin-x64": "@miden-sdk/node-darwin-x64",
"linux-x64": "@miden-sdk/node-linux-x64-gnu",
};
return platformMap[`${os.platform()}-${os.arch()}`] || null;
return platformMap[key] || null;
}

/**
* Detects the runtime C library on Linux. glibc builds report a glibc
* version in the process report; musl (Alpine) leaves it undefined. Returns
* `{ family, label }` where `family` ("gnu" | "musl") selects the platform
* package and `label` is a human-readable string for diagnostics. On
* non-Linux platforms `family` is null (libc selection does not apply).
*/
function detectLibc() {
if (_libc) return _libc;
if (os.platform() !== "linux") {
_libc = { family: null, label: os.platform() };
return _libc;
}
try {
const report = process.report.getReport();
const version = report.header.glibcVersionRuntime;
if (version) {
// Standard glibc Node reports its runtime version here.
_libc = { family: "gnu", label: `glibc ${version}` };
} else if (hasGlibcMarker(report)) {
// Unusual glibc build that hides glibcVersionRuntime but still links
// the glibc loader/libc -- keep it on the gnu binary, not musl.
_libc = { family: "gnu", label: "glibc" };
} else {
// No glibc runtime version and no glibc loader in the process image
// -> musl (Alpine). An absent glibcVersionRuntime is the primary musl
// signal; standard Alpine Node leaves it undefined.
_libc = { family: "musl", label: "musl" };
}
} catch {
// Detection unavailable -- assume the more common glibc.
_libc = { family: "gnu", label: "glibc (assumed)" };
}
return _libc;
}

/** True if the process image links the glibc dynamic loader / libc. */
function hasGlibcMarker(report) {
const objs = report && report.sharedObjects;
return Array.isArray(objs) && objs.some((f) => /ld-linux|\/libc\.so/.test(f));
}

/** `${platform}-${arch}`, with the libc family appended on Linux. */
function platformLabel() {
const base = `${os.platform()}-${os.arch()}`;
const libc = detectLibc();
return libc.family ? `${base} (${libc.label})` : base;
}

/** First line of an error's message, prefixed with its code when present. */
function firstLine(err) {
const code = err && err.code ? `${err.code}: ` : "";
const message = (err && err.message ? err.message : String(err)).split(
"\n"
)[0];
return `${code}${message}`;
}

/**
* Builds an actionable "module not found" error: the platform we are on, the
* package we looked for, the real failure of each attempt, and how to fix the
* common deployment causes.
*/
function buildNotFoundMessage(platformPackage, attempts) {
const lines = [
`Miden napi module not found for ${platformLabel()}, Node ${process.version}.`,
"",
"Resolution attempts:",
...attempts.map((a) => ` - ${a}`),
"",
];

if (platformPackage) {
lines.push(
`Expected the optional dependency "${platformPackage}" to be installed ` +
`and loadable. Common causes:`,
" - The optional dependency was skipped at install time (npm's " +
"cross-platform lockfile bug, --omit=optional / --no-optional, or a " +
"pruned or partially-copied node_modules in a Docker build).",
" - The base image's libc or CPU does not match a published binary " +
"(e.g. an Alpine/musl or arm64 image).",
""
);
} else {
lines.push("No prebuilt binary is published for this platform.", "");
}

lines.push(
"Fixes:",
" - Reinstall with optional dependencies on the target platform, e.g. " +
"`npm install --include=optional` (or delete node_modules + " +
"package-lock.json and reinstall on Linux; pnpm avoids this npm bug).",
" - On Alpine/musl, ensure @miden-sdk/node-linux-x64-musl is installed, " +
"or switch to a glibc base image such as node:22-bookworm-slim.",
" - Or set MIDEN_MODULE_PATH to a prebuilt .node file.",
" - Or build from source: `cargo build -p miden-client-web " +
"--no-default-features --features nodejs --release`."
);

return lines.join("\n");
}

/**
Expand Down
2 changes: 1 addition & 1 deletion crates/web-client/src/models/code_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ impl CodeBuilder {
/// Given a Library Path, and a source code, turn it into a Library.
/// E.g. A path library can be `miden::my_contract`. When turned into a library,
/// this can be used from another script with an import statement, following the
/// previous example: `use miden::my_contract'.
/// previous example: `use miden::my_contract`.
#[js_export(js_name = "buildLibrary")]
pub fn build_library(
&self,
Expand Down
Loading