Run the full Meshtastic node (meshtasticd, the portduino build) as
WebAssembly — in a browser tab or headless Node — driving a real LoRa
radio over WebUSB through a CH341 USB-to-SPI bridge. The same firmware
setup()/loop() that runs on desktop Linux, compiled to wasm, talking to an
SX1262 from a Chromium tab with no native code on the machine.
The desktop firmware drives LoRa over a CH341 via a userspace libusb driver
(libch341-spi-userspace) wrapped by Ch341Hal. This swaps that libusb backend
for WebUSB and bridges RadioLib's synchronous SPI to async WebUSB with
Emscripten Asyncify — one suspend per SPI transfer.
Companion to meshtastic/firmware: the wasm build env + WebUSB backend live there under
src/platform/portduino/wasm/(ARCH_PORTDUINO_WASM, built as a normal PlatformIO env —pio run -e native-wasmvia the meshtastic/platform-wasm platform). This repo is the web app, JS WebUSB runtime, and dev harness that consume it.
Live site: https://meshtastic.github.io/meshtasticd-wasm-node/ — open in Chromium, plug in a CH341 LoRa adapter, click Connect & boot node. No install, nothing leaves the tab. (The sections below are for building/hacking locally.)
- Full node boots in a tab over WebUSB, inits a real SX1262, joins the mesh — TX / RX / relays real traffic, AES, builds the node DB with pubkeys + telemetry.
- Headless too: the same wasm runs under Node via
node-usb(no browser). - Persistence: IDBFS in the browser / NODEFS headless — identity, config, and nodedb survive a reload.
- Region picker (live retune, no reboot) and a unique per-node MAC.
- API control via the firmware's own
PhoneAPI, exposed as wasm exports and driven by the official@meshtastic/coreSDK over an in-process transport (zero network), or by the Python CLI through a TCP :4403 bridge.
Prereqs: Chromium, a CH341 LoRa adapter (e.g. a MeshToad, E22/SX1262; VID
0x1A86 PID 0x5512), a sibling meshtastic/firmware checkout, and Node.
npm install
./tools/setup-emsdk.sh # one-time: fetch the Emscripten SDK (~1 GB, into ./emsdk)
npm run build:wasm # -> web/dist/meshnode.{mjs,wasm} (runs firmware's pio run -e native-wasm)Browser:
npm run serve # static server (WebUSB needs a secure context)
# open http://localhost:8080/web/ — full node + region + API proof (index)
# or http://localhost:8080/web/meshnode-api.html — full node + @meshtastic/core UI
# or http://localhost:8080/web/probe.html — hardware probe (no wasm)Click Connect & boot, grant the CH341. The node boots, the SDK configures it, and the node list + messaging go live.
Headless / Python CLI:
node tools/run-node.mjs # boot + run against the CH341 over node-usb
MESH_TCP=4403 node tools/run-node.mjs # serve the device API on TCP :4403, then:
meshtastic --host localhost --port 4403 --infoweb/index.html + meshnode.js Full node (the front page): boot, adapter + region picker, API proof
web/meshnode-api.html/.js Full node + official @meshtastic/core SDK (in-process transport)
web/probe.html + probe.js Hardware probe — SX1262 liveness over WebUSB (no wasm)
web/transport-wasm.js @meshtastic/core Transport over the wasm_api_* exports
web/fs-setup.js IDBFS (browser) / NODEFS (headless) persistence mount
web/adapters.js GENERATED CH341 adapter presets + applyAdapter (npm run gen:adapters)
src/protocol.js CH341 framing (bit reversal, 0xA8 SPI stream, 0xAB GPIO). Unit-tested.
src/ch341.js WebUSB CH341 transport
wasm/build_node.sh wrapper -> firmware's `pio run -e native-wasm`, stages web/dist/
wasm/bridge.js implements the C backend's webusb_* imports over src/ch341.js
tools/build-site.mjs bundle pages + SDK (esbuild) and stage the wasm -> _site/ (static site)
tools/serve.mjs static dev server (no-store)
tools/run-node.mjs headless node-usb runner (+ MESH_TCP serve, MESH_ADAPTER, MESH_REGION)
tools/tcp-bridge.mjs 0x94c3 stream-framed TCP :4403 bridge for the Python CLI
tools/gen-adapters.mjs regenerate web/adapters.js from firmware bin/config.d/lora-*.yaml
The wasm C glue + WebUSB backend live in the firmware repo
(meshtastic/firmware, src/platform/portduino/wasm/, ARCH_PORTDUINO_WASM) as the
single source of truth; wasm/build_node.sh here just invokes that build and
stages the artifacts. The adapter chooser is derived from firmware's canonical
CH341 config YAMLs (bin/config.d/lora-*.yaml) — regenerate with npm run gen:adapters.
- Sync → async. RadioLib calls SPI synchronously; WebUSB is Promise-only.
wasm/libpinedio_webusb.cimplements the libpinedio API and usesEM_ASYNC_JStoawaitthe JS transport; linking with Asyncify lets those synchronous C calls suspend the wasm stack. (bridge.jsre-readsModule.HEAPU8after every suspend — the heap can grow.) - No pthreads, no interrupts. RX/TX-done is detected by polling the SX126x IRQ
flags each loop tick (the firmware's
pollMissedIrqs()path), not a USB thread. - Cooperative loop. JS calls
wasm_setup()then pumpswasm_loop_once(); the loop's blocking delay becomesemscripten_sleep. - API.
wasm_api_to_radio/wasm_api_from_radiofeed/drain the firmware'sPhoneAPI(unframedToRadio/FromRadio); allwasm_api_*calls happen between loop ticks, never mid-suspend.
- Chromium only — Safari has no WebUSB.
- Linux: the CH341 must not be bound to a kernel driver (WebUSB can't detach
it); the SPI PID
0x5512is usually free — add a udev rule for permissions. - Windows: install the WinUSB driver for the device via Zadig.
The repo deploys to GitHub Pages as a fully static site — no server, no
backend. tools/build-site.mjs bundles the page modules and the
@meshtastic/core SDK with esbuild (every ../src / ../wasm import inlined),
stages the compiled meshnode.{mjs,wasm}, and writes a self-contained _site/
that works from the project-pages subpath. Persistence (IndexedDB) and WebUSB
both work on Pages — the node is single-threaded, so no COOP/COEP is needed.
npm run build:wasm # stage web/dist/meshnode.{mjs,wasm} (or set FW=<firmware> for pio's .pio/build)
npm run build:site # -> _site/ (then serve _site/ with any static server)CI does this on every push to main via
.github/workflows/pages.yml: it checks out a
pinned meshtastic/firmware ref, builds the node with pio run -e native-wasm (emcc + the platform-wasm
platform), bundles, and deploys. The wasm is rebuilt every deploy, so the site
never ships a stale binary; bump FIRMWARE_REF (or use the workflow's
firmware_ref input) to move to a newer firmware.
First-time setup: in the repo's Settings → Pages, set Source to GitHub Actions.
GPL-3.0-or-later. See LICENSE.