Building miniextendr for webR — R compiled to WebAssembly via Emscripten.

Status: supported, CI-validated. Three CI tiers run in .github/workflows/webr.yml: tier 1 (wasm32-unknown-emscripten cargo-check), tier 2 (full R CMD INSTALL of rpkg inside the webR container — emcc side-module link), and tier 3 (a webR Node session that drives library(miniextendr) against the wasm install). All three run on every push to main and on workflow_dispatch; on a pull request they run only when the PR carries the webr label (apply it when the PR touches wasm-specific code, or when a reviewer asks). A local just docker-webr-smoke recipe drives the same path inside the pinned webR Docker image.

Tracking: umbrella #470. Shipped: tier 1/2/3 CI (#480 / #491 / #492), cross-package wasm stubs (#493), side-module RUSTFLAGS (-Zdefault-visibility=hidden, #494), link_to_r() wasm gating (#482), webR base-image mirror (#496), the redundant -C relocation-model=pic flag dropped (#745), the base-image pin bumped to a tagged webR v0.6.0 / R 4.6.0 release (#755), dependency guidance (#752 — see “Dependencies and webR” below), the compiled-imports lint (#925, minirextendr::miniextendr_webr_import_lint()), and the informational testthat-under-wasm pass (#1255, SMOKE_TESTTHAT=1 — see “Building locally” below), and the arm64-native dev image (Dockerfile.webr-arm64, #788 / PR #916, validated on Apple Silicon in #1254 — see “arm64-native dev image” below). Open follow-ups: #495 (cross-crate trait dispatch), #747 (drop mirror creds once the GHCR package is public).

🔗Target

wasm32-unknown-emscripten — not wasm32-unknown-unknown. We need Emscripten’s libc/pthread shims because R itself relies on them; webR’s build of R links against emcc’s sysroot.

🔗Why Rust nightly is mandatory

webR’s reference Dockerfile installs nightly Rust with --component rust-src and --target wasm32-unknown-emscripten. The rust-src component is the giveaway: it enables cargo -Z build-std, which is nightly-only. Two reasons we genuinely need it (not just “the upstream uses it, so we copy”):

🔗1. Emscripten ABI must match the active emcc

rustup target add wasm32-unknown-emscripten ships a precompiled std, but that std was built against whatever Emscripten was current when the toolchain snapshot was cut. The Emscripten ABI shifts between releases — libc shim layout, exception model (Wasm exceptions vs JS exceptions), filesystem layer, syscall numbering. webR pins its own Emscripten via the ghcr.io/r-wasm/flang-wasm base image, and that version routinely diverges from what rustup’s snapshot saw.

Mismatched std against active emcc produces either link-time symbol errors or, worse, runtime UB in the libc layer. The only robust fix is to rebuild std from source against the live Emscripten toolchain — which is exactly what -Z build-std=std,panic_abort does. Stable cargo cannot do this.

🔗2. panic = "abort" for std itself

R-on-WASM doesn’t unwind the way native targets do — Emscripten’s exception support is its own world, and rwasm builds with panic = "abort" to dodge the entire issue. Cargo.toml’s [profile.*] panic = "abort" only affects your crate; the precompiled std shipped by rustup is built with panic = "unwind" and stable Cargo can’t relink it. -Z build-std=std, panic_abort rebuilds std itself with the matching panic strategy, so the panic-abort cfg is consistent across the whole call graph.

🔗Knock-on consequence

Anything we ship that targets webR is implicitly nightly. We don’t need to bend over backwards to keep the WASM code path stable-clean — feature gates that require nightly cargo are fine on this path, as long as the native (non-WASM) build remains stable-buildable.

🔗Building locally

Everything lives inside the Dockerfile.webr image (inherits ghcr.io/r-wasm/webr digest-pinned, layers just/autoconf/cargo-limit). amd64-only. On Apple Silicon Docker Desktop runs it under Rosetta, which is slow and can kill node mid-run (see “Rosetta: node traps on Apple Silicon” below). On Apple Silicon, prefer the native-arm64 alternative, “arm64-native dev image” below (Dockerfile.webr-arm64, WEBR_ARM64=1), which runs no amd64 code.

just docker-webr-build         # one-time image build (~5–10 min cold)
just docker-webr-shell         # interactive shell, repo bind-mounted at /work
just docker-webr-test          # cargo check miniextendr-api on wasm32 (fast)
just docker-webr-smoke         # full smoke: build wasm side-module + load in
                               # webR Node session (canonical smoke.mjs runner)

Base image and tags. The base-image pin points at ghcr.io/a2-ai/webr-mirror, which needs GHCR credentials until the package is public (#747). Without them, build from upstream at the same digest (the mirror preserves digests, so the bytes are identical):

just docker-webr-build --build-arg WEBR_BASE=ghcr.io/r-wasm/webr@sha256:3fbf3dc3537c5b31bca94dd797dc25364693b18d71b1d0975f318fbfd6269d86
just docker-webr-arm64-build --build-arg WEBR_AMD64_DONOR=ghcr.io/r-wasm/webr@sha256:3fbf3dc3537c5b31bca94dd797dc25364693b18d71b1d0975f318fbfd6269d86

Then run bash tests/webr-smoke.sh directly (with WEBR_ARM64=1 for the arm64 image): docker-webr-smoke, docker-webr-test and the docker-webr-arm64-shell / -run / -smoke recipes first rebuild through their build recipe, which uses the private default base (docker-webr-shell and docker-webr-run use the built image as is). Pick the image tag with just docker_webr_image=<tag> … (or docker_webr_arm64_image) on the recipes and WEBR_IMAGE=<tag> on the script; the defaults are miniextendr-webr-dev:latest and miniextendr-webr-dev-arm64:latest.

just docker-webr-smoke (tests/webr-smoke.sh) drives three phases inside the container, plus an optional fourth (--scaffold / WEBR_SCAFFOLD=1, #1270 — off by default, see below). Every phase runs in one container (docker run -d … sleep infinity, then docker exec per step), because each phase reads what the previous one left under /tmp (the wasm library, the scaffolds) and /opt/webr/src/dist (the Node bundle); CI gets the same from its single job container (#1290). --keep leaves the container running and prints how to enter and remove it.

  1. Native R CMD INSTALL of rpkg against /opt/R/current/bin/R to run the wrapper-gen pass and regenerate rpkg/src/rust/wasm_registry.rs. The snapshot is gitignored (regenerated on every host install — see “Generated artifacts” in the root CLAUDE.md), so this native pass is what produces it in the first place: a fresh clone has no snapshot at all, and a stale one would register the wrong set of R routines under wasm. (The cross-package fixtures under tests/cross-package/ ship deliberately empty stubs — content-hash 0000000000000000 — since they’re never deployed to webR; see #493.)
  2. wasm32 install — CC=emcc bash rpkg/configure followed by R CMD INSTALL --no-test-load --no-staged-install against /opt/webr/host/R-4.6.0/bin/R (webR’s own host R) with R_MAKEVARS_USER=/opt/webr/packages/webr-vars.mk. The install’s lazy-load loads rpkg’s Imports in that R, whose library tree is separate from the native R’s, so the smoke (like CI tier 2) first installs the Imports through it into a shared R_LIBS_USER (/tmp/r-shared-lib). Result lands in an empty temp library, /tmp/wasm-lib/miniextendr/, which phase 3 NODEFS-mounts; the smoke checks that libs/miniextendr.so starts with the wasm magic bytes (an ELF file there means a silent native fallback).
  3. webR Node session — rebuilds webR’s Node bundle, then runs the canonical runner tests/webr-node-smoke/smoke.mjs (the same script CI tier 3 uses), which imports file:///opt/webr/src/dist/webr.mjs (see “Running a webR session in Node” below), NODEFS-mounts the wasm R lib tree, installs the hard Imports from repo.r-wasm.org, and drives library(miniextendr) + packageVersion(). With SMOKE_TESTTHAT=1 (the local-smoke default; disable with SMOKE_TESTTHAT=0; restrict the files with SMOKE_TESTTHAT_FILTER=<regex>, see “Rosetta” below) it then runs an informational testthat pass (#1255): the runner installs testthat from repo.r-wasm.org, NODEFS-mounts rpkg/tests, and runs the suite against the wasm install (load_package = "installed", with MINIEXTENDR_STRESS unset so the opt-in gctorture blocks — which have their own CI job — don’t run under the much slower interpreter). Suite counts are reported but test failures never gate: many tests legitimately fail or skip under wasm (worker-thread / fork / subprocess assumptions). Only a harness error before the counts line turns the gate red, and a 20-minute in-runner budget abandons (without failing) a wedged suite.

Pass --scaffold (or set WEBR_SCAFFOLD=1) to add a local-parity reproduction of CI’s scaffold legs (#1259 standalone + #1271 monorepo) between phases 2 and 3: installs minirextendr from the checkout, scaffolds a fresh end-user package (mxsmoke) with create_miniextendr_package() and a fresh monorepo (mxmono) with create_miniextendr_monorepo(), points their framework git deps at the checkout via use_local_miniextendr(), and repeats the native → wasm two-step install on each into the same /tmp/wasm-lib phase 2 uses — phase 3 then also loads mxsmoke + mxmono alongside miniextendr (comma-separated SMOKE_SCAFFOLD_PKG). This is the local reproduction of a scaffold-leg CI failure without hand-driving the container; without the flag, behavior is unchanged. Closes #1270.

First cold run on Apple Silicon (measured 2026-09-27 while other builds shared the machine): about an hour for the amd64 image under Rosetta (phase 1 about 20 minutes, the wasm32 cargo build 14, the webR session with the testthat pass about 16), 27 minutes for the arm64-native image. Subsequent runs reuse the docker image and most cargo artefacts (the amd64 rerun took 23 minutes).

🔗Rosetta: node traps on Apple Silicon (#1254)

On Apple Silicon, Docker Desktop runs the amd64 image under Rosetta, and node can die with SIGTRAP (exit 133, Trace/breakpoint trap) right after Rosetta prints its own assertion:

assertion failed [block != nullptr]: BasicBlock requested for unrecognized address
(BuilderBase.h:550 block_for_offset)

The trap is in Rosetta’s translation of node’s JIT code, not in the package, the wasm module or R. It strikes at random points: a downstream package’s webR testthat suite trapped in 3 of 5 runs on one Mac, each time somewhere else (once at Initialising webR..., before any test ran), and the same tree passed on a rerun. The amd64 smoke run for #1254 hit it once, in phase 3 while webR installed the Imports from repo.r-wasm.org, and the automatic rerun passed.

Some code traps nearly every time rather than at random: one test trapped in four whole-suite runs in a row on three trees. When the same test traps on consecutive runs, a rerun is a poor bet. Run the files after it on their own with SMOKE_TESTTHAT_FILTER (a regex matched against test file names without the test- prefix and .R suffix, passed to testthat’s filter; unset or empty runs the whole suite), or use the arm64 image:

SMOKE_TESTTHAT_FILTER='^(dataframe|list)' just docker-webr-smoke

The smoke therefore reruns a failed step once when that step’s output holds the assertion text (tests/webr-smoke-retry.sh, tested without Docker by just test-webr-smoke-retry), says why, and counts the reruns in its final Smoke test PASSED line; a second trap on the same step fails the run. Exit 133 alone never triggers the rerun (133 is any SIGTRAP), and a trap inside make’s npm child still triggers it (make exits 2, the text is in the log). The arm64-native image below takes Rosetta out of the loop.

🔗arm64-native dev image (#788, validated in #1254)

Status: validated on Apple Silicon on 2026-09-27 (macOS 26.6.2, Docker Desktop 29.8.0). The image builds and the whole arm64 smoke passes in 27 minutes cold: the native aarch64 Linux install regenerates wasm_registry.rs, the arm64-built side-module loads in the webR Node session (library(miniextendr)), and the informational testthat pass reports passed=9062, failed=6, skipped=50, errors=100. Phase 1 needed miniextendr-api to compile for aarch64-unknown-linux-gnu, where c_char is u8; the ALTREP class-name pointers now use c_char (PR #1618), and the Rust lint CI job runs cargo check --target aarch64-unknown-linux-gnu so it stays that way. The amd64 image under Rosetta passes the same smoke with the same counts, after one automatic rerun of a trapped step (see “Rosetta: node traps on Apple Silicon” above).

The amd64 image runs on Apple Silicon only under Rosetta — slow, node can trap there (see “Rosetta: node traps on Apple Silicon” above), and the 2026-05-27 attempt at the full datafusion+arrow wasm compile under qemu exhausted host disk and crashed Docker Desktop. Dockerfile.webr-arm64 builds natively on arm64 by composing prebuilt parts, so there is no emulated execution and no source build of emcc / flang / R→wasm:

PieceSourceWhy no source build
emccemscripten/emsdk:5.0.7-arm64 (linux/arm64/v8, Ubuntu 24.04)emsdk ships prebuilt emcc per host arch
Rust nightly + wasm32-unknown-emscripten + rust-srcrustup --default-host aarch64-unknown-linux-gnuprebuilt arm64 toolchain
host R 4.6.0rig add 4.6.0prebuilt arm64 R
wasm R sysroot (/opt/webr/{wasm,R/build,tools,packages,dist,src}, R/R-VERSION)COPY --from= the amd64 mirrorwasm objects + headers + scripts are arch-portable; FS copy only

Neither flang (Fortran→wasm) nor the R→wasm build is needed: miniextendr is Rust + C with no Fortran, and the wasm R is already prebuilt — those upstream parts exist only to build R itself to wasm, which a dev image reuses.

just docker-webr-arm64-build         # build natively on arm64
just docker-webr-arm64-shell         # interactive shell, repo at /work
just docker-webr-arm64-smoke         # arm64 end-to-end smoke (WEBR_ARM64=1)

🔗Why emcc 5.0.7-arm64 specifically (the ABI match)

The emcc that links miniextendr.so must match the emcc that built the prebuilt wasm R, or the side-module won’t load. The pinned base (webR v0.6.0, #755) built its wasm R with Emscripten 5.0.7 (commit 263db4cf). emscripten/emsdk:5.0.7-arm64 (digest sha256:19b3a361d842…, pushed 2026-04-30) is the linux/arm64/v8 build of the same release and commit: same emcc on a different host arch ⇒ same wasm ABI. The image’s sanity layer compares its emscripten-version.txt with the one copied from the donor, so bumping the base pin without moving the emsdk tag fails the build. The first cut paired emsdk 4.0.8 with the 5.0.7 donor, and that 4.0.8 base was Ubuntu 22.04, too old (glibc 2.35) for the posit PM Noble binaries the image installs (#1254).

The #788 issue body assumed host R 4.5.1; the base image was since bumped to webR v0.6.0 / R 4.6.0 (#755), so the arm64 image pins rig add 4.6.0 to stay header-compatible with the copied wasm R. This is deliberately not the repo’s pinned dev R (rproject.toml’s 4.6) — it tracks whatever the prebuilt wasm R was built from.

🔗Validation checklist (#1254)

Checked on an Apple Silicon Mac (macOS 26.6.2, Docker Desktop 29.8.0, 14 CPUs / 16 GiB for the VM) on 2026-09-27, building from the upstream base at the pinned digest (see “Base image and tags” above) and running WEBR_ARM64=1 bash tests/webr-smoke.sh:

  • Image builds — just docker-webr-arm64-build completes: the donor COPY --from= resolves, the native toolchain installs, and the sanity layer passes (aarch64, emcc 5.0.7 equal to the donor’s, R 4.6.0, Node 22.16.0). About 3 minutes once the two base images are pulled (the donor pull took 2 minutes on its own).
  • Side-module ABI load — Phase 2 links miniextendr.so with the arm64 emcc (the file starts with the wasm magic bytes) and Phase 3’s library(miniextendr) loads it in a webR Node session.
  • Sysroot link/load — the amd64-built wasm sysroot under /opt/webr links and loads under the arm64-host emcc end to end (same run).
  • Native-R orchestration on arm64 — both passes run through the rig-installed arm64 R on PATH, and Phase 1’s native aarch64 Linux install regenerates wasm_registry.rs. This needed the c_char fix (PR #1618): before it, miniextendr-api did not compile for aarch64-unknown-linux-gnu (expected *const u8, found *const i8).
  • Node bundle rebuild — make /opt/webr/src/dist/webr.mjs succeeds with the copied /opt/webr/src tree and the bundled Node 22.16.0, and webR/config.ts carries R 4.6.0 (the image copies R/R-VERSION).
  • Compile weight / disk — the cold smoke took 27 minutes (about 10 for phases 1 and 2, 1 for the Node bundle, 16 for the webR session with the informational testthat pass). The image is 4.8 GB, the container’s writable layer 1.4 GB, and rpkg/rust-target 2.6 GB (2.0 GB aarch64 Linux, 0.6 GB wasm32). No disk pressure on a 60 GB Docker Desktop disk.

The image uses the current Rust nightly (1.100.0-nightly, 2026-09-25 on this run); the donor and CI use the nightly baked into the webR image (1.97.0-nightly, 2026-05-18). The run passed on the newer nightly, so the image does not pin it.

🔗How CC=emcc cooperates with our build

webR’s per-package install passes R_MAKEVARS_USER=webr-vars.mk which overrides CC=emcc, CXX=em++, LDFLAGS=-s SIDE_MODULE=1 -s WASM_BIGINT -s ASSERTIONS=1 …, and zeroes out LIBR/LIBINTL/STRIP_*. rpkg/configure.ac detects this — when CC matches emcc|em++ it sets:

  • IS_WASM_INSTALL=true (substituted into Makevars)
  • CARGO_BUILD_TARGET=wasm32-unknown-emscripten
  • RUST_TOOLCHAIN=+nightly (only if not already pinned)
  • CARGO_BUILD_STD_FLAG=-Z build-std=std,panic_abort

…and refuses to proceed if src/rust/wasm_registry.rs is absent (the wasm install path can’t run the host wrapper-gen pass that would otherwise regenerate it). rpkg/src/rust/build.rs enforces a related invariant: when building for wasm32 it parses the // generator-version: N header out of wasm_registry.rs and panics if it doesn’t match the constant mirrored from miniextendr-api/src/wasm_registry_writer.rs::GENERATOR_VERSION. Bump both together when the generated-file shape changes.

Makevars.in branches the $(WRAPPERS_R) recipe on IS_WASM_INSTALL: the native branch dyn.loads the freshly-built shared object and calls back into it to emit the wrappers, while the wasm32 branch is a no-op that reuses the pre-generated files (host R can’t dyn.load a wasm SIDE_MODULE, so wrappers and wasm_registry.rs must be produced by a prior native build and shipped in).

🔗Two R installations inside the container

webR’s image carries two distinct R trees and you have to reach for the right one:

PathUse
/opt/R/current/bin/RNative (rig-managed 4.6.0). Phase 1 of the smoke script — host wrapper-gen.
/opt/webr/host/R-4.6.0/bin/RwebR’s own host R, configured for wasm cross-compilation. Phase 2 — wasm R CMD INSTALL with webr-vars.mk.
/tmp/wasm-lib/Empty temp library the wasm install writes to (phase 2). NODEFS-mounted into the webR Node session.

R_SOURCE=/opt/webr/R/build/R-4.6.0 and WASM_TOOLS=/opt/webr/tools must be exported during the wasm install — webr-vars.mk references both. The arm64-native image has neither amd64 R: both passes run through its rig-installed arm64 R on PATH (see “arm64-native dev image” below).

🔗Other webR build constraints

  • Macro-emitted C symbols are crate-prefixed (#1273). Native dyn.load uses RTLD_LOCAL, so identical symbol names across package .sos never meet — but webR loads each package as an Emscripten SIDE_MODULE sharing one GOT, where the first-loaded module’s definition of an exported name wins for every later module. Two packages both exporting C_add meant the second package’s R_registerRoutines table captured function pointers into the first package’s code (observed in the #1259 scaffold leg). Every #[miniextendr]-generated #[no_mangle] symbol therefore carries the consuming crate’s name: C_<crate>_<fn>, C_<crate>_<Type>__<method>, __mx_altrep_reg_<crate>_<Ident>, __VTABLE_<crate>_..._FOR_..., etc. (single source of truth: miniextendr-macros/src/naming.rs). Registration name and linker symbol stay identical, so the generated wasm_registry.rs keeps reconstructing its extern declarations from R_CallMethodDef.name. Hand-written extern "C-unwind" fns, #[export_name = "..."], and c_symbol = "..." overrides pass through verbatim — if you use them, you own their cross-package uniqueness on webR (prefix them with your package name). Two R packages whose crates share a name still collide, but that requires a deliberate [lib] name mismatch — the scaffold derives crate name from package name, and R refuses to load two same-named packages anyway. (miniextendr-api’s own #[no_mangle] exports are byte-identical across packages and remain a theoretical interposition surface — tracked separately in #1310.)
  • linkme does not support wasm32-* targets. linkme-impl emits a unsupported_platform compile error for any target_os outside its whitelist. miniextendr leans on linkme::distributed_slice for runtime registration of R_CallMethodDefs, ALTREP class init, and trait dispatch tables; on WASM that’s replaced by a host-generated wasm_registry.rs that pre-bakes the same registrations at build time. Cross-crate trait dispatch on WASM is the remaining follow-up — tracked in #495.
  • No host execution of WASM during install. --no-test-load and --no-staged-install are mandatory. Anything that loads the side-module on the host (e.g. dyn.load-based wrapper-gen) is gated off via IS_WASM_INSTALL; R wrappers and wasm_registry.rs are pre-generated by Phase 1’s native build and shipped through into Phase 2’s source tree.
  • Worker thread is off. R-on-WASM is single-threaded; the worker-thread feature must be disabled. Already feature-gated.
  • RUSTFLAGS for the side-module are set by rpkg/configure.ac’s is_wasm_install branch (and mirrored into the minirextendr templates): -Zdefault-visibility=hidden (#494). This flag is load-bearing, not cosmetic: webR links the Rust staticlib into a -s SIDE_MODULE=1 shared object, and without hidden default visibility the staticlib exports ~3000 mangled stdlib/dep symbols into the side-module’s EXPORT table. webR’s JS-side dyn.load then fails — TypeError: Cannot read properties of undefined on the pinned emcc, or a hard emcc: error: invalid export name on emcc 4.0.8+. Hiding symbols by default leaves only the #[no_mangle] extern "C" entry points exported. This is savvy’s approach (yutannihilation/savvy#372), endorsed by webR’s maintainer (r-wasm/webr#532). Note -s SIDE_MODULE=1 is an emcc link flag supplied by webr-vars.mk, not a RUSTFLAG — the staticlib is a cargo build --lib archive cargo never links. (-C relocation-model=pic was set here too until #745: PR #749 proved the link succeeds without it and tier-3 confirmed the runtime load is unaffected — wasm32-unknown-emscripten is position-independent by default, so the flag was a no-op.)
  • blas-lapack needs webR’s wasm BLAS/LAPACK in PKG_LIBS. The package’s $(LAPACK_LIBS) $(BLAS_LIBS) $(FLIBS) line resolves from the install’s R_MAKEVARS_USER. webR’s packages/webr-vars.mk overrides FLIBS (flang’s libFortranRuntime.a) but leaves BLAS_LIBS / LAPACK_LIBS at the host R’s native libraries, which wasm-ld rejects as “unknown file type”. rwasm’s webr-vars.mk points them at /opt/webr/wasm/R-<ver>/lib/R/lib/libR{blas,lapack}.so; Phase 2 of the webR workflow (and tests/webr-smoke.sh) does the same with a wrapper makefile that includes webr-vars.mk and adds two override lines. The wasm dgemm_ / dgesv_ signatures (15 / 8 i32 parameters) match sys::dgemm_ / sys::dgesv_, the hidden CHARACTER lengths included. See LINKING.md.

🔗Running a webR session in Node (the two-bundle gotcha)

webR’s esbuild config emits two bundles and only one runs in Node:

BundlePathRuntime
browser/opt/webr/dist/webr.mjsbrowser only — stubs out fs/worker_threads/url via blankImportPlugin, crashes in Node
Node/opt/webr/src/dist/{webr.mjs,webr.cjs}Node — the .mjs carries a __dirname/__filename/createRequire banner

So the Node runner imports the Node bundle, with no baseUrl:

import { WebR } from "file:///opt/webr/src/dist/webr.mjs";
const webR = new WebR({ interactive: false });   // NO file:// baseUrl

Two non-obvious constraints:

  1. The image deletes src/dist and src/node_modules (its Dockerfile runs make clean to shrink the published image, see .webr/Dockerfile). You must rebuild the Node bundle first: cd /opt/webr/src && make /opt/webr/src/dist/webr.mjs. That target chains npm ci → webR/config.ts (sed from .in) → npm run build (tsc + esbuild) → an asset-copy of R.wasm/R.js/vfs/webr-worker.js out of /opt/webr/dist into /opt/webr/src/dist, so the bundle resolves all runtime assets via its own __dirname — hence no baseUrl needed (~20s on a warm runner).
  2. Do NOT set a file:// baseUrl. Node 18+’s new Worker(string) rejects file:// URL strings with ERR_WORKER_PATH, so the bundle crashes at init while building the webr-worker.js worker path. (This is the trap the old import { WebR } from "file:///opt/webr/dist/webr.mjs" + baseUrl advice walked straight into — that imported the browser bundle, which can’t run in Node at all.)

tests/webr-node-smoke/smoke.mjs (CI tier-3) is the worked reference; its header comment is the source of truth for the bundle layout. The Node process must webR.close() (terminates the worker) and call process.exit() in a top-level .finally() — otherwise the worker keeps Node’s event loop alive and the run hangs until the watchdog timeout kills it (exit 124, see reviews/2026-05-29-tier3-webr-node-smoke-exit-hang.md).

🔗Dependencies and webR

The wasm R CMD INSTALL finishes with a lazy-load / byte-compile step that spawns a host R whose .libPaths() points into the wasm library tree (see “Two R installations” above). If your package’s NAMESPACE eagerly imports a compiled package — importFrom(somePkg, …) or import(somePkg) where somePkg ships a .so — that step calls loadNamespace(somePkg), the host R tries to dyn.load the wasm-built somePkg.so, and dies:

unable to load shared object '.../somePkg.so': invalid ELF header

This is the same host-R-loads-a-wasm-object failure that bites webR’s own base packages — handled by installing to an empty temp library, the install-to-temp-lib pattern; see tier 2 / #491 / #744. The difference is the trigger: #491/#744 are about webR’s own base-package .sos, whereas this failure is driven by your package’s own declared imports, so the framework can’t paper over it for you.

🔗Why --no-byte-compile alone doesn’t save you

Both the wasm-install scripts and CI pass --no-byte-compile (see tests/webr-smoke.sh and .github/workflows/webr.yml, mirroring rwasm’s flag set). That suppresses the byte-compile half of the cascade, but the lazy-load step that materialises the namespace still runs loadNamespace() for everything in your Imports/Depends namespace-load graph. Skipping byte-compilation does not prune your declared imports, so an importFrom of a compiled package still reaches for its .so. The only robust fix is to keep the compiled dependency out of the namespace-load graph entirely — see the guidance below.

Guidance:

  • Pure-R dependencies (withr, …) are safe to importFrom — they have no .so for the host R to choke on. But “pure R” must hold for the whole Depends/Imports graph, not just the package itself, and verify before assuming: many common utility packages are compiled despite their pure-R reputation — rlang, cli, glue, fs, and purrr are all NeedsCompilation: yes and ship a libs/ directory, and lifecycle (itself pure R) hard-imports compiled cli + rlang (verified 2026-06). Check the installed package for libs/ or read NeedsCompilation from its DESCRIPTION, or just run the lint below — it walks the graph for you.
  • Compiled or heavy dependencies you only need at runtime (Shiny, DBI backends, data.table, …) belong in Suggests, not Imports. Call them with pkg::fn() behind a requireNamespace() guard. That keeps them out of the namespace-load graph, so the wasm install’s lazy-load never reaches for their .so. (Pure-R umbrellas count too: shiny itself has no .so, but its hard Imports — httpuv, later — do.)
  • If you control the wasm build environment, a third option is to make native copies of every compiled namespace import reachable by the host R during the wasm R CMD INSTALL — the lazy-load sub-R then resolves the native .so, never a wasm one. This is how rpkg itself gets away with hard-importing compiled cli/vctrs: tier 2 pre-installs native copies into a shared R_LIBS_USER before the wasm install. It does not help for builds you don’t control (rwasm / repo.r-wasm.org) — there, keep compiled deps out of the namespace-load graph as above.

This mirrors what a downstream package did (moved its Shiny stack to Suggests + ::). Documented under #752; the lint is minirextendr::miniextendr_webr_import_lint() (also reachable as miniextendr_doctor(webr = TRUE), #925). It statically probes each namespace-level import — libs/ dir or NeedsCompilation field of the installed copy, recursing through pure-R umbrellas’ Depends/Imports — and falls back to a curated known-compiled list for dependencies that are not installed locally. No loadNamespace(), no network.

🔗CI

.github/workflows/webr.yml runs three tiers plus two scaffold legs (#1259 standalone, #1271 monorepo) and a monorepo-template wasm check (monorepo-wasm-check, see below). Every job except the cheap pin-lockstep digest grep is gated on PRs behind the webr label (push-to-main and workflow_dispatch run them unconditionally). Tier 1 is cargo check --target wasm32-unknown-emscripten for miniextendr-api plus the two cross-package stub crates (#493), on labeled PRs matching the paths filter (miniextendr-api/**, miniextendr-macros/**, miniextendr-engine/**, miniextendr-lint/**, rpkg/**, minirextendr/**, tests/cross-package/**, tests/webr-node-smoke/**, tests/webr-smoke.sh, Cargo.{toml,lock}, Dockerfile.webr, Dockerfile.webr-arm64, .github/workflows/webr.yml). It catches cfg-gating regressions and macro-emission bugs that fail to compile on wasm32; it does not catch link errors or runtime issues — those are tier 2/3 work.

Tier 1 needs no R on the runner: link_to_r() in miniextendr-api/build.rs (and its miniextendr-engine sibling) skips libR resolution when CARGO_CFG_TARGET_ARCH is wasm32 (#482). The rpkg/src/rust cargo check is still absent from tier 1 for a different reason: rpkg/configure invokes Rscript directly for feature detection and the bare-ubuntu runner has no R installed — tier 2 exercises the full rpkg install path inside the webR container instead.

Tier 2 + 3 run as a single webr-install job inside the webR container (ghcr.io/a2-ai/webr-mirror, a digest-preserved mirror of ghcr.io/r-wasm/webr — see #496 / .github/workflows/mirror-webr.yml). The job runs the same three phases as the local smoke: Phase 1 (native install regenerates wasm_registry.rs), Phase 2 (emcc wasm install → /tmp/wasm-lib/miniextendr, the empirical validator for the side-module RUSTFLAGS), then tier 3 — the Node + webR session (tests/webr-node-smoke/smoke.mjs) that NODEFS-mounts the wasm install, installs the package’s Imports from repo.r-wasm.org, and drives library(miniextendr). Tier 2 only proves the side-module links; tier 3 is what proves it loads in a real webR runtime. On main-push and workflow_dispatch runs (not per-PR — wall time gates PRs) tier 3 also runs the informational testthat pass (#1255, SMOKE_TESTTHAT=1, never gating on test failures — see “Building locally” above).

The same job also runs a scaffold leg (#1259): it installs minirextendr from the checkout, scaffolds a fresh end-user package with create_miniextendr_package(), points the scaffold’s framework git deps at the checkout via use_local_miniextendr() (the .miniextendr-local marker → [patch."https://github.com/A2-ai/miniextendr"] in the generated .cargo/config.toml), and repeats the native→wasm two-step install on it — native R CMD INSTALL (wrapper-gen writes the scaffold’s wasm_registry.rs), a roxygen2::roxygenise() pass + reinstall (the scaffolded NAMESPACE is a stub until the documented document() step runs), then the CC=emcc install into the same /tmp/wasm-lib. Tier 3 then loads the scaffolded package alongside miniextendr (SMOKE_SCAFFOLD_PKG) and calls the template’s stock functions add() / hello(). rpkg also exports an add, and that collision is now intentional coverage: since #1273 the generated wrapper symbols are crate-prefixed (C_mxsmoke_add vs C_miniextendr_add), so both packages loaded into one shared-GOT webR session must dispatch to their own implementations — mxsmoke::add(2, 3) returns 5 via mxsmoke’s own f64 path while miniextendr’s i32 add keeps working. Before #1273 the leg had to rename the scaffold’s stock fns to dodge the first-loaded module’s symbol winning the GOT. This is the only CI coverage of the template copies of the wasm branches in configure.ac / Makevars.in / build.rs (minirextendr/inst/templates/rpkg/) — before it, a template-only regression could only surface for end users.

The monorepo template tree (minirextendr/inst/templates/monorepo/rpkg/) carries its own copies of the same wasm branches and gets two-tier coverage (#1271). Per-PR, the cheap monorepo-wasm-check job scaffolds a fresh monorepo with create_miniextendr_monorepo(), runs one native R CMD INSTALL (to regenerate wasm_registry.rs — the wasm build hard-fails on a missing/stub copy), then cargo check --target wasm32-unknown-emscripten on the scaffolded rpkg crate: no emcc link, no webR container, but it covers the template build.rs/cfg-gating drift class. On main-push and workflow_dispatch, the webr-install job additionally runs the full monorepo scaffold leg: the same native → roxygenise → native → CC=emcc sequence on the monorepo scaffold (mxmono — its stock add()/hello() are kept as-is, extending the intentional #1273 collision to a third package with a crate name, mxmono-core, that differs from the package name, so the leg also proves the prefix is the crate name: C_mxmono_core_add), installed into the same /tmp/wasm-lib; tier 3 then loads it alongside miniextendr and mxsmoke (SMOKE_SCAFFOLD_PKG is a comma-separated list). Local smoke parity for both legs is tests/webr-smoke.sh --scaffold (#1270, above).

🔗See also

  • Issue #470 — umbrella tracking issue for webR/WASM support.
  • Issue #495 — cross-crate trait dispatch; #752 — dependency guidance (this section); #925 — lint for importFrom of compiled deps (miniextendr_webr_import_lint(), shipped); #788 — arm64-native dev image (Dockerfile.webr-arm64 + the docker-webr-arm64-* just recipes + the WEBR_ARM64=1 smoke path; checked on Apple Silicon in #1254, see its checklist); #1255 — the informational testthat-under-wasm pass (SMOKE_TESTTHAT=1, shipped).
  • Issues #491 / #744 — the base-package variant of the host-R-loads-a-wasm- object failure, solved via the install-to-temp-lib pattern (the dependency guidance above is the consumer-package-imports variant of the same failure).
  • tests/webr-node-smoke/smoke.mjs — the CI tier-3 Node runner (single source of truth for the runtime smoke; tests/webr-smoke.sh Phase 3 invokes it).
  • tests/webr-smoke.sh — the local end-to-end smoke runner. Mirrors the green webr.yml tier-2/3 job step-for-step (Phase 2 → /tmp/wasm-lib, Phase 3 → make the Node bundle, then run smoke.mjs), including the CI-only scaffold leg behind --scaffold / WEBR_SCAFFOLD=1 (#1270 — off by default). Every phase runs in one container (#1290). The default (amd64) image runs under Rosetta on Apple Silicon, and a step that hits the Rosetta trap is rerun once (tests/webr-smoke-retry.sh, #1254); WEBR_ARM64=1 selects the Dockerfile.webr-arm64 native-arm64 path (#788).
  • Dockerfile.webr-arm64 — native-arm64 dev image (#788, validated in #1254): amd64 sysroot donor + emscripten/emsdk:5.0.7-arm64 + native arm64 Rust/R. See “arm64-native dev image” above for the validation checklist.
  • .webr/ — vendored clone of the webR repo for offline reference.
  • .webr/Dockerfile — upstream Rust toolchain install we inherit.
  • .webr/packages/webr-vars.mk — the Makevars override webR uses for every R package install under WASM.