WebR / WASM support
Building miniextendr for webR — R compiled to WebAssembly via Emscripten.
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.
- Native
R CMD INSTALLofrpkgagainst/opt/R/current/bin/Rto run the wrapper-gen pass and regeneraterpkg/src/rust/wasm_registry.rs. The snapshot is gitignored (regenerated on every host install — see “Generated artifacts” in the rootCLAUDE.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 undertests/cross-package/ship deliberately empty stubs — content-hash0000000000000000— since they’re never deployed to webR; see #493.) - wasm32 install —
CC=emcc bash rpkg/configurefollowed byR CMD INSTALL --no-test-load --no-staged-installagainst/opt/webr/host/R-4.6.0/bin/R(webR’s own host R) withR_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 sharedR_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 thatlibs/miniextendr.sostarts with the wasm magic bytes (an ELF file there means a silent native fallback). - 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 importsfile:///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 driveslibrary(miniextendr)+packageVersion(). WithSMOKE_TESTTHAT=1(the local-smoke default; disable withSMOKE_TESTTHAT=0; restrict the files withSMOKE_TESTTHAT_FILTER=<regex>, see “Rosetta” below) it then runs an informational testthat pass (#1255): the runner installs testthat fromrepo.r-wasm.org, NODEFS-mountsrpkg/tests, and runs the suite against the wasm install (load_package = "installed", withMINIEXTENDR_STRESSunset 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 foraarch64-unknown-linux-gnu, wherec_charisu8; the ALTREP class-name pointers now usec_char(PR #1618), and the Rust lint CI job runscargo check --target aarch64-unknown-linux-gnuso 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:
| Piece | Source | Why no source build |
|---|---|---|
| emcc | emscripten/emsdk:5.0.7-arm64 (linux/arm64/v8, Ubuntu 24.04) | emsdk ships prebuilt emcc per host arch |
Rust nightly + wasm32-unknown-emscripten + rust-src | rustup --default-host aarch64-unknown-linux-gnu | prebuilt arm64 toolchain |
| host R 4.6.0 | rig add 4.6.0 | prebuilt arm64 R |
wasm R sysroot (/opt/webr/{wasm,R/build,tools,packages,dist,src}, R/R-VERSION) | COPY --from= the amd64 mirror | wasm 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.0to 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-buildcompletes: the donorCOPY --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.sowith the arm64 emcc (the file starts with the wasm magic bytes) and Phase 3’slibrary(miniextendr)loads it in a webR Node session. -
Sysroot link/load — the amd64-built wasm sysroot under
/opt/webrlinks 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
RonPATH, and Phase 1’s native aarch64 Linux install regenerateswasm_registry.rs. This needed thec_charfix (PR #1618): before it, miniextendr-api did not compile foraarch64-unknown-linux-gnu(expected *const u8, found *const i8). -
Node bundle rebuild —
make /opt/webr/src/dist/webr.mjssucceeds with the copied/opt/webr/srctree and the bundled Node 22.16.0, andwebR/config.tscarries R 4.6.0 (the image copiesR/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-target2.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 intoMakevars)CARGO_BUILD_TARGET=wasm32-unknown-emscriptenRUST_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:
| Path | Use |
|---|---|
/opt/R/current/bin/R | Native (rig-managed 4.6.0). Phase 1 of the smoke script — host wrapper-gen. |
/opt/webr/host/R-4.6.0/bin/R | webR’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.loadusesRTLD_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 exportingC_addmeant the second package’sR_registerRoutinestable 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 generatedwasm_registry.rskeeps reconstructing itsexterndeclarations fromR_CallMethodDef.name. Hand-writtenextern "C-unwind"fns,#[export_name = "..."], andc_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] namemismatch — 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.) linkmedoes not supportwasm32-*targets.linkme-implemits aunsupported_platformcompile error for anytarget_osoutside its whitelist. miniextendr leans onlinkme::distributed_slicefor runtime registration ofR_CallMethodDefs, ALTREP class init, and trait dispatch tables; on WASM that’s replaced by a host-generatedwasm_registry.rsthat 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-loadand--no-staged-installare mandatory. Anything that loads the side-module on the host (e.g.dyn.load-based wrapper-gen) is gated off viaIS_WASM_INSTALL; R wrappers andwasm_registry.rsare 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-threadfeature must be disabled. Already feature-gated. RUSTFLAGSfor the side-module are set byrpkg/configure.ac’sis_wasm_installbranch (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=1shared object, and without hidden default visibility the staticlib exports ~3000 mangled stdlib/dep symbols into the side-module’s EXPORT table. webR’s JS-sidedyn.loadthen fails —TypeError: Cannot read properties of undefinedon the pinned emcc, or a hardemcc: error: invalid export nameon emcc 4.0.8+. Hiding symbols by default leaves only the#[no_mangle] extern "C"entry points exported. This issavvy’s approach (yutannihilation/savvy#372), endorsed by webR’s maintainer (r-wasm/webr#532). Note-s SIDE_MODULE=1is an emcc link flag supplied bywebr-vars.mk, not aRUSTFLAG— the staticlib is acargo build --libarchive cargo never links. (-C relocation-model=picwas 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-lapackneeds webR’s wasm BLAS/LAPACK inPKG_LIBS. The package’s$(LAPACK_LIBS) $(BLAS_LIBS) $(FLIBS)line resolves from the install’sR_MAKEVARS_USER. webR’spackages/webr-vars.mkoverridesFLIBS(flang’slibFortranRuntime.a) but leavesBLAS_LIBS/LAPACK_LIBSat the host R’s native libraries, whichwasm-ldrejects as “unknown file type”.rwasm’swebr-vars.mkpoints them at/opt/webr/wasm/R-<ver>/lib/R/lib/libR{blas,lapack}.so; Phase 2 of the webR workflow (andtests/webr-smoke.sh) does the same with a wrapper makefile that includes webr-vars.mk and adds twooverridelines. The wasmdgemm_/dgesv_signatures (15 / 8i32parameters) matchsys::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:
| Bundle | Path | Runtime |
|---|---|---|
| browser | /opt/webr/dist/webr.mjs | browser 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:
- The image deletes
src/distandsrc/node_modules(its Dockerfile runsmake cleanto 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 chainsnpm ci→webR/config.ts(sed from.in) →npm run build(tsc + esbuild) → an asset-copy ofR.wasm/R.js/vfs/webr-worker.jsout of/opt/webr/distinto/opt/webr/src/dist, so the bundle resolves all runtime assets via its own__dirname— hence nobaseUrlneeded (~20s on a warm runner). - Do NOT set a
file://baseUrl. Node 18+’snew Worker(string)rejectsfile://URL strings withERR_WORKER_PATH, so the bundle crashes at init while building thewebr-worker.jsworker path. (This is the trap the oldimport { WebR } from "file:///opt/webr/dist/webr.mjs"+baseUrladvice 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.sofor the host R to choke on. But “pure R” must hold for the wholeDepends/Importsgraph, 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 allNeedsCompilation: yesand ship alibs/directory, and lifecycle (itself pure R) hard-imports compiled cli + rlang (verified 2026-06). Check the installed package forlibs/or readNeedsCompilationfrom itsDESCRIPTION, 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, notImports. Call them withpkg::fn()behind arequireNamespace()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 sharedR_LIBS_USERbefore 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
importFromof compiled deps (miniextendr_webr_import_lint(), shipped); #788 — arm64-native dev image (Dockerfile.webr-arm64+ thedocker-webr-arm64-*just recipes + theWEBR_ARM64=1smoke 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.shPhase 3 invokes it).tests/webr-smoke.sh— the local end-to-end smoke runner. Mirrors the greenwebr.ymltier-2/3 job step-for-step (Phase 2 →/tmp/wasm-lib, Phase 3 →makethe Node bundle, then runsmoke.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=1selects theDockerfile.webr-arm64native-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.