* Trap memory64 scalar out-of-bounds/overflow before native pointer use: internal/codegen/emit_memops.go * Trap memory64 scalar out-of-bounds/overflow before native pointer use: internal/codegen/helpers/helpers.go * Regression: memory64 scalar loads/stores must trap on u64 overflow * Make greater-than-4GiB memory certification fail closed when required * Add required real >4 GiB Memory64 CI gate * Repair Memory64 CI workflow YAML after broken replacement * CLI: add strict -require-memory64 mode for native AOT builds * Test strict memory64 CLI accepts wasm64 and refuses wasm32 * Document strict Memory64 AOT mode and real 4 GiB proof requirements * Register memory64 bounds helper in generator dependency closure * Test memory64 bounds helper and keep extractor-only function referenced * Align compiler-source lint/vet with generated benchmark policy (Makefile) * Align compiler-source lint/vet with generated benchmark policy (internal/testfixture/fixture.go) * Correct Memory64 scalar boundary regression to actually test out-of-range address * Fix Go formatting of memory64 bounds test table * Fix escaped newline expectations in Memory64 scalar runtime regression * Fix trailing comma in Memory64 test composite literal
7.4 KiB
wasm2go
wasm2go is an ahead-of-time compiler that translates a WebAssembly
binary into standalone Go source code. The generated package runs
the module natively — no interpreter and no embedded wasm runtime — so
it builds and links like any other Go code.
It is useful when you want to ship a wasm-compiled library (for example a large C/C++ codebase built with the WASI SDK) inside a Go program without paying the startup and memory cost of a wasm engine.
The generated output is dual-arch: every function ships an amd64.s
and an arm64.s body produced by an SSA-based register allocator
(block-local regalloc + cross-block stack allocation + m-pointer
caching + slot reuse + peephole + branch-fusion), with a pure-Go
fallback selected by build constraint on any other GOOS/GOARCH. The asm and pure-Go paths share
the same New() / exported-method API, so callers never see the
distinction.
Install
go install github.com/goccy/wasm2go/cmd/wasm2go@latest
Prebuilt binaries for each release are published on the releases page.
CLI usage
Translate a wasm binary to a single Go file:
wasm2go -i module.wasm -o module.go -pkg mymodule
By default the input is read from stdin and the output is written to
stdout, so wasm2go < module.wasm > module.go also works.
Inspect a module without generating code:
wasm2go -dump -i module.wasm
Flags
| Flag | Description |
|---|---|
-i |
Input wasm file (default: stdin). |
-o |
Output Go file (default: stdout). Single-file mode only. |
-pkg |
Go package name in the output (default: wasm2go). |
-import |
Go import path of the generated package (required). |
-out-dir |
Output directory; required when the wasm exceeds the internal multi-file threshold. |
-dump |
Print a summary of the parsed module instead of generating code. |
-require-memory64 |
Reject wasm32 or modules without 64-bit-indexed memory. |
-bulk-export-prefix |
Treat exports matching <prefix><svc>_<mt> as bulk-dispatch entries (one standalone Inv_<svc>_<mt> per match so the linker can drop unused ones). |
-keep-dead-funcs |
Disable whole-function dead-code elimination (useful for diffing). |
-entry-exports |
Comma-separated list of export names that are DCE roots; the literal value NONE means no export is a root. |
-promotion-report |
Write the memory-promotion report (JSON: per-function frame/rodata/slab classification) to this path. |
The translator's output shape (SSA pipeline, data-sidecar layout,
native wasi_snapshot_preview1, multi-package + linkname-split for
large modules) is auto-derived from the input. There is no caller-
visible knob for any of those decisions.
Memory64 / native 64-bit builds
Memory64 is a source-module property, not a switch that widens wasm32 pointers. Hanzo's fork already supports i64-indexed memory, WASIp1 64-bit bindings, native code generation, and C/C++ wasm64 fixtures.
For required 64-bit builds, refuse an accidental wasm32 artifact:
wasm2go -i module.wasm -pkg native \
-import example.com/app/internal/native \
-out-dir ./internal/native -require-memory64
A 64-bit Go host does not widen a wasm32-wasip1 module. A genuine
Memory64 source (e.g. from a compatible wasm64 compiler and sysroot) is
required; do not assume stock Rust has a wasm64-wasip1 std target.
The scalar memory64 emit path checks address + offset for 64-bit
overflow and ensures the full access width is in bounds before native
pointer arithmetic. TestMem64ScalarOverflowTraps checks this behavior.
The >4GiB proof test can skip on development hosts without enough
memory. CI runs it with WASM2GO_REQUIRE_MEM64_4G=1, where a
resource refusal fails certification instead of being counted as
a pass. Do not advertise full 64-bit certification until that gate,
the amd64/arm64 tests, and conformance checks all pass.
Security: generated native Go does not retain WASM sandbox isolation. Untrusted WASM requires a separate sandbox backend.
WASI support
When the input wasm imports wasi_snapshot_preview1, wasm2go emits
a native Go implementation of every preview1 call alongside the
translated module. The implementation is one self-contained file
that only uses Go's standard library (os, time, syscall,
net) — there is no platform-specific file split and no third-party
dependency, so generated output builds for every GOOS Go supports
(linux, darwin, freebsd, netbsd, openbsd, dragonfly, solaris,
illumos, aix, windows) without any extra wiring on the consumer
side.
Library usage
The translator is also importable as a library via the
github.com/goccy/wasm2go/transpile package:
package main
import (
"os"
"github.com/goccy/wasm2go/transpile"
)
func main() {
in, _ := os.Open("module.wasm")
defer in.Close()
out, _ := os.Create("module.go")
defer out.Close()
if _, err := transpile.Transpile(in, out, transpile.Options{
Package: "mymodule",
OutputImportPath: "example.com/myproj/mymodule",
}); err != nil {
panic(err)
}
}
Transpile is a one-shot wrapper around Parse followed by
Translate; call Parse and Translate directly when you need to
inspect the parsed Module between the two steps. See the
transpile.Options documentation for the full set of knobs; they
mirror the CLI flags above.
Performance
Generated Go runs natively, so there is no wasm interpreter on the hot path. The win shows up most in two places:
- Cold start — no JIT / AOT compilation step at program startup.
- Per-call overhead — calls into the module are ordinary Go function calls; no host-↔-guest boundary crossing.
A representative measurement against an equivalent wazero-driven
build of the same wasm module (a ~13 MB SQL parser/analyzer wasm,
22 SQL benchmark queries; macOS / Apple Silicon;
go test -bench=. -benchtime=200ms):
| Workload | wazero | wasm2go | Speedup |
|---|---|---|---|
| One-time wasm cold compile | 4.67 s | 0.56 s | 8.4× |
select_constant (trivial scalar) |
49.3 µs | 2.7 µs | 18× |
rank_dense_rank (window) |
220 ms | 8.4 ms | 26× |
lag_lead (window) |
229 ms | 10.0 ms | 23× |
json_extract_pipeline |
878 ms | 10.8 ms | 81× |
q1_lineitem_pricing_summary (TPC-H, ~64 ms scan) |
64.2 ms | 68.2 ms | 0.94× |
q3_shipping_priority (TPC-H, ~34 s scan) |
33.9 s | 37.3 s | 0.91× |
wasm2go wins by large factors on short queries (call-boundary overhead dominates the wazero path), and is within ±10% on the long scan-heavy TPC-H queries where the inner loop work dominates the boundary.
Memory allocations per call drop in proportion (e.g. select_constant:
76 → 41 allocs/op, 2.2 KB → 1.3 KB per query).
Development
make install/wat2wasm # install the wat2wasm CLI the test suite needs
make build # build the CLI into ./bin
make test # run the test suite with the race detector
make test-cover # run with coverage and enforce the threshold
make lint # run golangci-lint
make release/check # validate the GoReleaser config + dry-run a build
make install/wat2wasm dispatches to Homebrew on macOS and to
apt/dnf/pacman/apk on Linux (whichever is available); it is a no-op
if wat2wasm is already on PATH.
License
MIT — see LICENSE.