diff --git a/.github/workflows/publish-web-sdk.yml b/.github/workflows/publish-web-sdk.yml index ed0c0537..0b1154fe 100644 --- a/.github/workflows/publish-web-sdk.yml +++ b/.github/workflows/publish-web-sdk.yml @@ -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 @@ -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' }} steps: - name: Check out code uses: actions/checkout@v6 @@ -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 @@ -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: @@ -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 @@ -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); diff --git a/.gitignore b/.gitignore index 35110def..afe5525e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd9627a..694fc837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 16b565ab..5869ad45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/crates/web-client/js/node/loader.js b/crates/web-client/js/node/loader.js index 17a798d4..34b3d5e8 100644 --- a/crates/web-client/js/node/loader.js +++ b/crates/web-client/js/node/loader.js @@ -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"; @@ -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. @@ -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) @@ -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" }; @@ -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)}`); + } } } @@ -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"); } /** diff --git a/crates/web-client/src/models/code_builder.rs b/crates/web-client/src/models/code_builder.rs index cbb40113..b3e258bb 100644 --- a/crates/web-client/src/models/code_builder.rs +++ b/crates/web-client/src/models/code_builder.rs @@ -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, diff --git a/packages/node-sdk-linux-x64-gnu/package.json b/packages/node-sdk-linux-x64-gnu/package.json index b5ee5583..1422d440 100644 --- a/packages/node-sdk-linux-x64-gnu/package.json +++ b/packages/node-sdk-linux-x64-gnu/package.json @@ -8,6 +8,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "main": "miden_client_web.node", "files": [ "miden_client_web.node" diff --git a/packages/node-sdk-linux-x64-musl/package.json b/packages/node-sdk-linux-x64-musl/package.json new file mode 100644 index 00000000..99259ce8 --- /dev/null +++ b/packages/node-sdk-linux-x64-musl/package.json @@ -0,0 +1,24 @@ +{ + "name": "@miden-sdk/node-linux-x64-musl", + "version": "0.15.5", + "description": "Miden Client SDK native module for Linux x64 (musl / Alpine)", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "main": "miden_client_web.node", + "files": [ + "miden_client_web.node" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/0xMiden/web-sdk.git", + "directory": "packages/node-sdk-linux-x64-musl" + } +}