bee-wasm-js
v0.1.1
Published
Build, test and run Swarm WASM modules for Bee's experimental WASM compute engine
Maintainers
Readme
bee-wasm-js
Write, build, test and run WebAssembly modules for Swarm.
A Bee node can download a WASM module from
Swarm and run it: POST /@/{address} hands the request body to the module on
stdin and returns what it wrote to stdout. While it runs, the module may call
back into the node to read and write Swarm data through a host module named
swarm.
This SDK gives you the whole loop in JavaScript tooling:
write AssemblyScript → bee-wasm build → runLocal (in-memory node) → upload → executeExperimental. The execute endpoint is off by default; an operator enables it with
--wasm-execute-enable. Output is not reproducible across nodes — a host call reads what that node happens to hold — there is no gas metering, and modules run in the node's own process. Don't enable it on a public gateway.
Install
npm install bee-wasm-jsNode 22.18 or newer. The runtime dependencies are the AssemblyScript compiler and bee-js, which the node client is built on.
Quickstart
1. Write a module. It reads its input from stdin and writes its result to
stdout. _start is the entrypoint, like any WASI command.
// assembly/index.ts
import { bytesGet, codeName, readInput, writeOutput, writeOutputString } from "bee-wasm-js/assembly";
export function _start(): void {
const reference = readInput(); // the request body: a 32-byte reference
const result = bytesGet(reference); // fetch what it points at
if (!result.ok) {
writeOutputString("error: " + codeName(result.code));
return;
}
writeOutput(result.unwrap());
}2. Build it. The output is a few kilobytes and imports nothing but
wasi_snapshot_preview1 and swarm, which is what the sandbox demands.
npx bee-wasm build assembly/index.ts -o build/module.wasm
# build/module.wasm 3849 bytes3. Run it against an in-memory node, with no Bee running anywhere:
import { MemoryHost, runLocal, toHex } from "bee-wasm-js";
import { readFileSync } from "node:fs";
const host = new MemoryHost();
const reference = host.seedBytes("hello swarm");
const result = runLocal(readFileSync("build/module.wasm"), { input: reference, host });
console.log(result.status); // "ok"
console.log(Buffer.from(result.output).toString()); // "hello swarm"
console.log(result.hostCallsUsed, result.hostBytesUsed); // 1 114. Put it on Swarm and run it there:
export BEE_API_URL=http://localhost:1633
export BEE_POSTAGE_BATCH=<your batch id>
REF=$(npx bee-wasm upload build/module.wasm)
npx bee-wasm exec "$REF" --input-hex <a reference to fetch>or from JavaScript:
import { BeeClient } from "bee-wasm-js";
const client = new BeeClient({ url: "http://localhost:1633", batchId: process.env.BEE_POSTAGE_BATCH });
const address = await client.uploadModule(readFileSync("build/module.wasm"));
const result = await client.execute(address, { input: someReference });Writing a module
Guest code is AssemblyScript: TypeScript
syntax, WebAssembly semantics. It is not JavaScript — there is no any, numbers
are typed (i32, u32, usize, f64), and there are no exceptions, which
is why every call here returns a result code instead of throwing.
Import the guest library as bee-wasm-js/assembly.
Input and output
| Function | Meaning |
|---|---|
| readInput(): Uint8Array | the whole request body, from stdin |
| writeOutput(data: Uint8Array) | append to the response body |
| writeOutputString(text: string) | the same, UTF-8 encoded |
| writeError(text: string) | stderr — discarded by the node, captured by the harness |
| getEnv(name: string): string | any request-derived variable; the host environment is never inherited |
Reading the request
The rest of the request arrives through the environment, CGI-style:
| Function | Variable | Meaning |
|---|---|---|
| requestMethod() | REQUEST_METHOD | the HTTP method |
| pathInfo() | PATH_INFO | the path after the address — this is what you route on |
| queryString() | QUERY_STRING | the raw, undecoded query |
| scriptName() | SCRIPT_NAME | where the module is mounted, e.g. /@/{address} |
| requestURI() | REQUEST_URI | the full request target |
| requestContentType() | CONTENT_TYPE | of the request body |
| requestContentLength() | CONTENT_LENGTH | -1 when the node reported none |
| requestHeader(name) | HTTP_* | an allowlisted request header, by its ordinary name |
/@/{address} and /@/{address}/{path} both serve, and PATH_INFO is what
distinguishes them: empty for the bare form, / for a trailing slash, /x/y
beyond. scriptName() + pathInfo() is always the path the client asked for,
which is what to build self-links from — hardcoding an address breaks behind the
/v1/@/... alias.
The node forwards only a fixed set of request headers — Accept, User-Agent,
Swarm-Postage-Batch-Id and a handful more — and never Authorization or
Cookie, whatever the operator configures. So an empty requestHeader may mean
the client did not send it or that the node does not forward it.
Every HTTP method reaches the module, so one module can serve several verbs:
export function _start(): void {
if (requestMethod() == "GET") writeOutputString("<h1>hello</h1>");
else writeOutputString("send me a GET");
}OPTIONS is the exception: the node answers it itself as a CORS preflight, so
untrusted code never sees it.
Responding
By default the node decides how your output is rendered: the status comes from
the verdict and the content type from the caller's Accept. A module serving a
web page needs both itself — a stylesheet is not text/html just because the
browser asked for text/html.
setStatus(code: i32): Code
setHeader(name: string, value: string): Code
setContentType(value: string): Code
redirect(location: string, code: i32 = 303): Codeexport function _start(): void {
if (pathInfo() == "/style.css") {
setContentType("text/css");
setHeader("Cache-Control", "max-age=3600");
writeOutputString("body { font: 16px system-ui }");
return;
}
setStatus(404);
setContentType("text/plain");
writeOutputString("not found\n");
}Neither call charges the host-call budget — they cause no node work — and both
work on a node with node access switched off. They have their own bounds instead:
32 headers and 8 KiB of name-plus-value, BUDGET_EXHAUSTED beyond.
Four rules worth internalising:
- Only the outermost execution has a response. A module reached through
executeModulegetsDENIED, so a module you fetched from Swarm cannot rewrite the content type you are serving. - Only a clean run commits. A module that traps sets no headers, exactly as it stores nothing. Its partial output does survive — that is evidence about what went wrong, while a header would be an instruction to follow.
- A status must be 200–599. 5xx is allowed:
Swarm-Wasm-Statusstill saysok, which is what distinguishes your 500 from the node's. - Some names are refused with
DENIED:Swarm-Wasm-*,Access-Control-*,Set-Cookieand the origin-wide security headers — every module shares the node's origin with the node's own authenticated API — plus the hop-by-hop and framing headers. A malformed name or a control character in a value isINVALID, which is also what stops CR/LF injection.
How this reaches a client depends on what it asked for. Accept:
application/json reports your status and headers in the envelope as
httpStatus and headers without applying them. Every other representation
applies them. A wildcard Accept reports by default and applies once you have
set something — which is what lets a browser load your stylesheet, since a
subresource request never names text/html.
Reaching the node
bytesGet(address: Uint8Array, hint: i32 = 4096): GetResult
bytesPut(batchId: Uint8Array, data: Uint8Array): PutResult
chunkGet(address: Uint8Array): GetResult
chunkPut(batchId: Uint8Array, data: Uint8Array): PutResult
executeModule(address: Uint8Array, input: Uint8Array, hint: i32 = 4096): ExecResultbytes* moves data of arbitrary length through the same splitter and joiner the
/bytes endpoints use. chunk* is the raw single-chunk pair: a chunk is an
8-byte little-endian span followed by at most 4096 bytes, 4104 in total.
Addresses, references and batch ids are always 32 bytes.
Every call returns a result carrying a code, an ok flag, and the payload:
const result = bytesGet(reference);
if (result.ok) {
writeOutput(result.unwrap()); // Uint8Array
} else if (result.code == Code.NOT_FOUND) {
writeOutputString("nothing there");
}unwrap() returns the payload, or aborts with a message on stderr when the call
did not succeed. Aborting is a legitimate choice — it is a clean trap verdict —
but note that a trap discards anything the execution uploaded, so branch on
the code if you have already stored something you want to keep.
Result codes
| Code | Name | Meaning |
|---|---|---|
| 0 | OK | the call succeeded |
| 1 | NOT_FOUND | nothing is stored at that address |
| 2 | DENIED | the node refused: an unusable batch, or a second batch in one execution |
| 3 | BUDGET_EXHAUSTED | the call, byte or depth budget is spent |
| 4 | BUFFER_TOO_SMALL | the payload did not fit — handled for you, see below |
| 5 | INVALID | a pointer is out of bounds, or the arguments are malformed |
| 6 | EXEC_FAILED | a nested module trapped or was invalid |
Nothing traps. A pointer outside linear memory is INVALID, not a crash: the
node bounds-checks every offset a module hands it.
Failures inside the node — its storer erroring, a watchdog firing — are never
reported through these codes. They end the execution with status host-error
and a 500, because a node-local failure is not a verdict on your program.
Budgets
The caller provides the buffer for a read, so the node never grows guest memory
mid-call. bytesGet handles the two-call protocol for you: it asks once with a
hint-sized buffer and, if the payload did not fit, asks again with the exact
length the node reported.
| Bound | Default | Header | Stops |
|---|---|---|---|
| host calls | 64 | Swarm-Wasm-Host-Calls-Limit | fetch amplification |
| host bytes | 32 MiB | Swarm-Wasm-Host-Bytes-Limit | memory and bandwidth blowup |
| depth | 4 | Swarm-Wasm-Depth-Limit | runaway recursion |
| response headers | 32 | — | unbounded response metadata |
| response header bytes | 8 KiB | — | the same, by size |
Headers may only lower a limit; the operator's configured maximum wins.
Spending them well:
- a read that fits the hint costs one call; a read that does not costs two, but the bytes are charged only on delivery, so the payload is paid for once either way.
chunkGetalways fits — a chunk has a known maximum — so it is always one call. Prefer it when you know you are addressing a single chunk.hint: 0is a pure probe, worth it only when a payload is expected to be far larger than the default 4 KiB and you want to allocate exactly once.- an upload is charged its declared length before the splitter runs, so an oversized put is refused without the node ever chunking it.
- the byte budget is one pool counting both directions.
Uploads
A module can only upload with a batch id it was given — it has no way to
enumerate the node's batches, so this path grants no authority the HTTP API does
not already grant. The node resolves the batch exactly as POST /chunks does;
an unusable or unknown one is DENIED.
const batchId = input.subarray(0, 32);
const payload = input.subarray(32);
const result = bytesPut(batchId, payload);
if (result.ok) writeOutput(result.unwrap()); // the 32-byte referenceThree rules worth internalising:
- One execution gets one batch. A put naming a different batch than the first
is
DENIED. - Uploads are deferred. A put returns once the data is stored locally; the pusher syncs it afterwards. When the HTTP response arrives the data is in the node's upload store, not yet acknowledged by the network.
- Only a clean run commits. A module that traps, or is cut off, leaves nothing behind.
Encryption and redundancy are not exposed: bytesPut writes unencrypted at the
default redundancy level, which is what keeps a reference 32 bytes wide.
Nested execution
executeModule fetches another module from Swarm and runs it, handing it your
input on stdin and returning its stdout:
const result = executeModule(moduleAddress, input);
if (result.ok) writeOutput(result.unwrap());The budgets are shared across the whole call tree, so a module cannot
multiply its allowance by recursing; a cycle simply runs out of depth. The depth
limit counts levels including the outermost, so 1 permits no nesting at all. A
nested module is always run as a WASI command — an entrypoint header applies to
the outermost module only.
Building
bee-wasm build <entry.ts> [options]
-o, --out <file> output path (default build/module.wasm)
--text also emit the WebAssembly text format
--debug unoptimized build with debug info
--entrypoint <name> verify against this export instead of _start
--max-memory <bytes> cap linear memory
--runtime <name> stub | minimal | incremental (default stub)or programmatically:
import { build } from "bee-wasm-js";
const { wasm } = await build({ entry: "assembly/index.ts", outFile: "build/module.wasm" });Two things the build does beyond calling asc:
It removes the env imports. AssemblyScript imports abort, seed and
trace from a module named env, and the sandbox allows no namespace but
swarm and wasi_snapshot_preview1. The build redirects them to WASI-based
replacements shipped in assembly/runtime.ts — abort writes its message to
stderr and exits, which the node reports as a trap. A module that does not import
this library at all has nothing to redirect to, so the imports are dropped
instead and an abort becomes a bare unreachable.
It applies the sandbox's own rules. After compiling, the module is checked
the way checkImports in pkg/compute/wazero.go checks it: no import outside
the two allowed namespaces, no unknown swarm.* function, no imported memory, an
exported _start (or the entrypoint you named), and a memory exported as
memory — which is how the harness reads guest buffers, and which
AssemblyScript emits by default. A
module that would come back invalid-module from a node fails the build with an
explanation instead.
You can run that check on any module, whoever built it:
bee-wasm inspect build/module.wasmimport { checkModule } from "bee-wasm-js";
const problems = checkModule(wasm); // [] means the sandbox accepts itThe default stub runtime is a bump allocator that never frees, which is what
you want for a module that runs once and exits. Choose incremental if your
module allocates in a loop and needs the memory back.
Testing locally
runLocal runs a module the way the node does — same errno table, same budgets,
same commit-on-success rule — against an in-memory Swarm:
import { MemoryHost, runLocal } from "bee-wasm-js";
const host = new MemoryHost();
const reference = host.seedBytes("some data");
const result = runLocal(wasm, {
input: reference, // Uint8Array or string
method: "POST", // becomes REQUEST_METHOD
url: "/blob/abc?raw=1", // PATH_INFO and QUERY_STRING in one
scriptName: "/@/abc", // where the module is mounted
headers: { accept: "text/css" },
host, // omit for a fresh MemoryHost, null for no node access
limits: { hostCalls: 8, hostBytes: 1 << 20, depth: 2 },
});
result.status; // "ok" | "trap" | "invalid-module" | "host-error"
result.output; // Uint8Array
result.response; // { status, headers } the module set, or undefined
result.stderr; // what the module wrote to stderr, including abort messages
result.hostCallsUsed; // budget actually spent
result.hostBytesUsed;MemoryHost is a real little Swarm: seedBytes splits data into chunks the way
the node does and returns a root reference, seedChunk stores one chunk
verbatim, and bytes(reference) / chunk(address) read back what a module
uploaded — but only after a successful run, because uploads are staged and
committed exactly as they are on a node.
const result = runLocal(uploadModule, { input: concat(batchId, payload), host });
host.bytes(result.output); // what the module stored, if it succeededOptions worth knowing:
new MemoryHost({
batches: ["<hex batch id>"], // anything else is DENIED; by default any non-zero id works
address: (chunk) => myBmtHash(chunk),
});What the harness does not model
- Addressing. References are SHA-256 digests, not Swarm BMT references, so a
reference computed locally will not resolve on a real node. Pass
addressif you have a real implementation. The structure is faithful: spans, 4096-byte leaves, 128-reference intermediate chunks. - The memory ceiling. The node enforces it in-engine; the harness does not.
- WASI. The harness implements the syscalls this SDK's modules use, plus the
handful a Rust or TinyGo runtime touches on startup. A module reaching for
anything else fails loudly with the function's name rather than pretending it
is invalid — the node provides all of preview1, so such a module may still run
there.
HARNESS_WASI_FUNCTIONSis the list.
A custom host is just an object; it must be synchronous, because a host call
is: the guest blocks inside swarm_bytes_get until the node answers.
import { HostRefusal, type SwarmHost } from "bee-wasm-js";
const host: SwarmHost = {
bytesGet(address) {
const data = myStore.get(toHex(address));
if (!data) throw new HostRefusal("not-found"); // the module sees NOT_FOUND
return data;
},
// throwing anything else ends the run as host-error, as a node-local failure should
...
};Talking to a node
import { BeeClient } from "bee-wasm-js";
const client = new BeeClient({ url: "http://localhost:1633", batchId });
const address = await client.uploadModule(wasm);
const result = await client.execute(address, {
input: body, // Uint8Array or string
method: "POST",
path: "/style.css", // reaches the module as PATH_INFO
query: "v=2", // QUERY_STRING
headers: { "Swarm-Postage-Batch-Id": batchId },
entrypoint: "run", // Swarm-Wasm-Entrypoint
hostCalls: 16, // Swarm-Wasm-Host-Calls-Limit
hostBytes: 1 << 20, // Swarm-Wasm-Host-Bytes-Limit
depth: 2, // Swarm-Wasm-Depth-Limit
memory: 16 << 20, // Swarm-Wasm-Memory-Limit
});BeeClient is a thin layer over bee-js:
uploads and downloads are bee.data.upload and bee.data.download, and
client.bee is the Bee instance itself, so the rest of the node API — stamps,
pinning, feeds, manifests — is available through it. Only execute is spelled
out here, because the endpoint is experimental and bee-js does not cover it.
const batches = await client.bee.stamp.getAll(); // the whole bee-js APIexecute returns a verdict on the program and throws only when the node
itself refused. A trap or an invalid module comes back as HTTP 400 carrying the
same JSON envelope a 200 does, so status, output and trapMessage are
available either way; 404, 406, 413, 429 and 500 raise bee-js's
BeeResponseError.
Ask for a different representation with accept: application/json (the
default) returns the envelope, application/octet-stream and text/html return
the module's raw output, which is what makes a module servable as a web page.
CLI
bee-wasm build <entry.ts> [options] compile AssemblyScript to a module
bee-wasm inspect <module.wasm> list imports, exports and objections
bee-wasm run <module.wasm> [options] run against an in-memory node
bee-wasm upload <module.wasm> [options] store a module on Swarm
bee-wasm exec <address> [options] run a module on a noderun takes --seed <file> (store a file in the in-memory node first, printing
its reference), --input <file> or --input-hex <hex>, --method, --url,
--script-name, --header name:value, --batch and the three budget flags. It
prints the status and headers the module set on stderr, so stdout stays pipeable.
exec takes --path, --query, --header and --accept as well. upload and exec read $BEE_API_URL and
$BEE_POSTAGE_BATCH when --bee and --batch are absent.
echo "swarm data" > data.txt
bee-wasm run build/module.wasm --seed data.txt
# seeded data.txt at 7154e973...254b
bee-wasm run build/module.wasm --seed data.txt --input-hex 7154e973...254b
# status=ok host_calls=1 host_bytes=11
# SWARM DATAExamples
Each one is a complete module in examples/, exercised by the tests in test/:
| Example | What it shows |
|---|---|
| echo | stdin, stdout and REQUEST_METHOD; no host calls at all |
| transform | fetching a reference, branching on NOT_FOUND instead of trapping |
| upload | storing the request body and returning its reference |
| nested | running another module with swarm_execute |
| webpage | routing on PATH_INFO, per-path content types, a redirect and a 404 |
npm run examples # build them all into examples/*/build/
npm test # build them, then run the suiteStaying in sync with Bee
The ABI, the result codes and the budgets are bee's, not this SDK's. When they move, these are the files to read:
| bee | what it defines |
|---|---|
| pkg/compute/README.md | the guest ABI, in prose |
| pkg/compute/host.go | the host functions, result codes and budget arithmetic |
| pkg/compute/limits.go | the default budgets |
| pkg/compute/wazero.go | the import rules and how a run is classified |
| pkg/api/execute.go | the endpoint, its headers and the response envelope |
| pkg/api/host.go | what a host call actually does to the node |
| pkg/api/execute_env.go | the CGI environment and the request-header allowlist |
| pkg/api/router.go | the two routes and how PATH_INFO is derived |
src/types.ts mirrors the constants, src/verify.ts mirrors the import rules,
src/cgi.ts mirrors the environment derivation and src/harness/run.ts mirrors
the budget accounting and the response caps.
