SPORE in the browser

The same SPORE node that runs on a laptop or an ESP32 also runs in a web page. The Rust core is compiled to a small WebAssembly module, and a thin JavaScript layer turns whatever the browser can talk over — a WebSocket, a WebRTC data channel, a Nostr relay — into a SPORE link. A page becomes a full peer: it signs its own messages, relays other people's, and needs no server of its own.

import { loadSpore, Hub, ZERO_DEST } from './spore.mjs';
import { WebSocketTransport } from './transports/websocket.mjs';

const spore = await loadSpore(fetch('./spore.wasm'));
const hub = new Hub(spore.newNode());
hub.onDeliver = (env) => console.log('got:', new TextDecoder().decode(spore.payload(env)));

hub.addTransport(new WebSocketTransport('wss://relay.example/spore'));
hub.send(ZERO_DEST, new TextEncoder().encode('hello mesh'));

That's the whole surface: load the wasm, make a node, wrap it in a hub, and attach transports. The hub is the browser twin of the Rust daemon's bridge hub — a frame that arrives on one transport is fed to the router and its forwards are relayed onto all the others.

Deep dive — how the wasm and the hub fit together

No wasm-bindgen. spore.wasm is a plain wasm32-unknown-unknown build with exactly one import: env.spore_fill_random(ptr, len). loadSpore supplies it by calling crypto.getRandomValues over the module's own memory. On the Rust side this is wired with getrandom's custom backend (register_custom_getrandom!), so the whole crypto stack (ed25519, x25519, ChaCha20-Poly1305, blake2, sha2) runs unchanged in the browser. You can confirm the single import with WebAssembly.Module.imports(new WebAssembly.Module(bytes)).

That single import is the clearest example of the model in Design's "The spore and the soil": the core asks its runtime for randomness rather than reaching for a source itself, which is exactly why the same build runs in a browser tab and in a daemon. Time works the same way (now is a parameter, never a clock the core reads). The browser is a thin runtime in one respect worth knowing: there is no disk to spill the store to, and the node stops when the last tab closes.

Calling convention. Rust hands variable-length results back as one i64 that packs a pointer and length: (ptr << 32) | len. JS reads the two halves with BigInt.asUintN(64, …), copies the bytes out of wasm memory, then calls spore_free. Buffers going in are written into memory obtained from spore_alloc. All of this is hidden inside spore.mjs — you only ever see Uint8Arrays.

The node ABI (src/wasm.rs) exposes: spore_node_new/free, spore_node_addr, spore_node_subscribe, spore_node_send(dest, payload, now) and spore_node_recv(bytes, now). The two hot calls return a packed blob of two lists — { forwards, delivered } — where forwards are envelopes to relay onward and delivered are envelopes addressed to (or subscribed by) this node. spore.mjs parses that blob back into arrays of Uint8Array.

Sealed mail is a separate set of calls, and the distinction matters: spore_node_send is the raw, unsealed, unsigned path — the one you use to prove a transport carries bytes, not the one a person sends another person. For that, node.sendDirect(dest, payload) seals to the peer's prekey (or through a §7 ratchet session, if one exists) and signs.

Call For
node.announce() flood this node's prekey/topics. Send one on connect — until a peer has heard it, they have no key to seal to you and will fall back to cleartext
node.sendDirect(dest, payload) a sealed, signed DM
node.canSealTo(dest) would that DM actually be sealed? Ask before you promise it
node.openDm(sender, payload, ratcheted) open a delivered DM; null if it does not open
spore.src(env) the authenticated sender — null for unsigned, unverified, or SRC8
spore.flags(env) FLAG_ENCRYPTED, FLAG_RATCHET — the latter picks which scheme openDm uses

Two rules a UI has to follow. Never draw a padlock unconditionally: sendDirect falls back to cleartext when no key is known, correctly and silently, so canSealTo is what earns the icon. Key threads on spore.src, never on a claimed field — a signed envelope proves its own sender, and anything weaker is spoofable.

The Hub owns one node and N transports. On addTransport(t) it replaces t.receive so inbound frames route through node.recv; deliveries fire onDeliver, and forwards are send() onto every other transport (split-horizon). hub.send(dest, payload) originates a message from this node onto all transports.

Transports

A transport is any object with a send(bytes) method that calls receive(bytes) when a frame arrives. Eleven are included; writing your own is a dozen lines. The standalone build inlines ten of them — Web NFC is left out because it needs Chrome on Android over HTTPS, which a file:// page is not.

Transport File Use
WebSocket transports/websocket.mjs relay or direct peer; works in browser and Node 22+
WebRTC transports/webrtc.mjs direct browser-to-browser; manual copy/paste (manualOffer/manualAnswer) or your own signaling; no server after connect
WebTorrent transports/webtorrent.mjs join a swarm by name; real bittorrent-tracker rendezvous, then P2P over WebRTC
Nostr transports/nostr.mjs any Nostr relay becomes a SPORE bag (kind-30078, tag spore-v1)
Meshtastic transports/meshtastic.mjs a Meshtastic LoRa node over USB or Bluetooth; envelope rides a MeshPacket (portnum 256), codec ported from the Rust bridge::meshtastic
Reticulum / RNode transports/reticulum.mjs an RNode LoRa modem over USB or Bluetooth in host/KISS mode; you set the radio (freq/bw/sf/cr/tx)
Web Serial transports/webserial.mjs a generic USB KISS TNC/board; interops with the Rust serial bridge
Web Bluetooth transports/webbluetooth.mjs a generic BLE radio over the Nordic UART Service; KISS-framed
Audio modem transports/audio.mjs data-over-sound; a 16-FSK modem bit-compatible with the Rust bridge::audio
Web NFC transports/webnfc.mjs tap-to-transfer at a few centimetres; one envelope per application/x-spore NDEF record. Chrome on Android over HTTPS only, so it is not inlined into the standalone build — import it from transports/
Loopback transports/loopback.mjs in-memory link between two hubs, for tests and offline demos

The KISS framing shared by the serial and Bluetooth transports lives in transports/kiss.mjs and matches Rust's src/kiss.rs byte-for-byte, so a browser tab and a physical board speak the same wire. The Meshtastic and Reticulum transports are honest device drivers (Meshtastic protobuf / RNode host protocol) but are not hardware-verified in CI — treat them as templates to confirm against your firmware.

Deep dive — writing a transport, and the ones you'd add next

The base class is trivial:

import { Transport } from './spore.mjs';
export class MyTransport extends Transport {
  send(bytes) { /* put bytes on the wire */ }
  // call this.receive(bytes) whenever a frame comes back
}

send may be async and should queue while the underlying channel is still opening (the WebSocket and WebRTC transports show the pattern). Frames are whole SPORE envelopes — one per message; don't split or concatenate them.

Browser media not yet wrapped but that fit the same shape: WebTransport (QUIC datagrams — closest match to SPORE's datagram model) and NFC (Web NFC, for a tap-to-seed). Each is a send/receive pair over its own API; the hub and node above don't change.

Run the tests

# build the wasm the JS loads
cargo build --release --lib --target wasm32-unknown-unknown

cd web
node test.mjs           # loopback: two hubs, one link, publish + verify
npm install             # pulls `ws` for the next one
node ws-test.mjs        # real WebSocket relay: A -> relay -> B

Both print an OK line. test.mjs needs nothing but Node (it runs the wasm); ws-test.mjs stands up a throwaway ws relay on a random port and sends a signed message through it.

One-file node

node build-standalone.mjs inlines the wasm and every transport into a single spore-standalone.html — a complete, functional node that runs from a file:// path, a USB stick, or an email attachment, making zero network requests until you add a bridge. It boots one live node, then lets you wire it at runtime to a WebSocket relay, a direct WebRTC peer, a Nostr relay, a Meshtastic or Reticulum LoRa radio (USB or Bluetooth), the speakers/mic (audio modem), or a WebTorrent swarm — signing, relaying, and delivering across all of them at once. Its identity and its bridges are remembered in the browser's local storage (the 32-byte signing seed via node.seed() / newNode(seed)), so it returns as the same node; network bridges reconnect on load, device bridges wait for a click. It's the smallest "a whole node in one file" seed; see docs/CONTINUITY.md.

This same file is the web node (the site's /demo/ page): the Pages workflow builds it once and serves it at /demo/ — one URL, not the same ~720 KB payload shipped twice under two paths. docs/APPS.md's "Download" button points at that same URL with an HTML download attribute, so opening it runs the node and downloading it saves the identical file. There is no separate demo page to keep in sync — the thing you download and the thing you run in the browser are one file.