Files
galaxy-game/ui/docs/wasm-toolchain.md
T
Ilia Denisov fbc0260720 phase 5: wasm core, GalaxyClient skeleton, Connect-Web stubs
Compile `ui/core` to WebAssembly via TinyGo (903 KB) and expose four
canonical-bytes / signature-verification functions on
`globalThis.galaxyCore` from `ui/wasm/main.go`. The TypeScript-side
`Core` interface plus a `WasmCore` adapter (browser + JSDOM loader)
bridge those into a typed shape, and a `GalaxyClient` skeleton wires
`Core.signRequest` → injected `Signer` → typed Connect client →
`Core.verifyPayloadHash` / `verifyResponse`.

Wire `ui/buf.gen.yaml` against the local
`@bufbuild/protoc-gen-es` v2 binary (devDependency) so the codegen
step does not depend on the buf.build BSR. Vitest covers the bridge
end-to-end: per-method WasmCore tests under JSDOM, byte-for-byte
canon parity against the gateway fixtures committed in Phase 3, and
a `GalaxyClient` orchestration test using
`createRouterTransport`. The committed `core.wasm` snapshot tracks
TinyGo output so contributors run `make wasm` only when `ui/core/`
changes; CI consumes the snapshot directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:58:37 +02:00

111 lines
4.2 KiB
Markdown

# WASM Toolchain
The Galaxy UI client compiles the Go module `ui/core` (canonical
bytes, signature verification, keypair helpers) to WebAssembly via
**TinyGo**. The compiled artefact `core.wasm` and its companion
runtime shim `wasm_exec.js` ship under `ui/frontend/static/`.
## Why TinyGo
Two viable Go-to-WASM toolchains exist:
| Toolchain | Bundle size (Phase 5) | Notes |
|---------------|------------------------------------|--------------------------------------------|
| **TinyGo** | ~903 KB (under 1 MB acceptance bar) | LLVM-based, no full GC, fast cold-start |
| Standard Go | ~2 MB (`GOOS=js GOARCH=wasm`) | Drops in without extra tooling |
`ui/core` was written under the TinyGo invariants documented in
`ui/core/README.md` (no `crypto/x509`, no `encoding/pem`, no
goroutines or `sync` primitives in production files), so the TinyGo
build is a drop-in compile.
The standard-Go fallback stays available in case TinyGo lags behind a
future Go release we depend on. To switch the build, swap the
`tinygo build` invocation in `ui/Makefile` for
`GOOS=js GOARCH=wasm go build -o frontend/static/core.wasm ./wasm`,
and copy the matching shim from
`$(go env GOROOT)/lib/wasm/wasm_exec.js`.
## Prerequisites
- TinyGo ≥ 0.41 (`brew install tinygo`).
- Go 1.26+ (TinyGo's host compiler).
- `buf` 1.67+ on PATH for TS protobuf generation
(`brew install bufbuild/buf/buf`).
- pnpm + Node 22+ for the JS runtime.
## Build commands
```bash
make -C ui wasm # produces frontend/static/{core.wasm,wasm_exec.js}
make -C ui ts-protos # regenerates frontend/src/proto/* from gateway/proto
```
`make wasm` runs `tinygo build -target=wasm` and copies the matching
TinyGo shim into the static asset directory. The shim **must** be
the TinyGo one — the standard Go shim is ABI-incompatible. The
Makefile resolves the shim path via `tinygo env TINYGOROOT`.
## Loading recipes
### Browser
SvelteKit serves `static/` at the application root, so the WASM
adapter at `ui/frontend/src/platform/core/wasm.ts` does the
following on first call:
1. Inject `<script src="/wasm_exec.js">` if `globalThis.Go` is
undefined.
2. `fetch('/core.wasm')` and instantiate via
`WebAssembly.instantiate`.
3. Spawn the Go runtime via `go.run(instance)`.
4. Read `globalThis.galaxyCore` and wrap it as the typed `Core`
interface.
The module is cached for the life of the page; subsequent
`loadCore()` calls reuse the same `Core` instance.
### Vitest under JSDOM
JSDOM does not implement `fetch` against `file://` URLs, so the
test loader at `ui/frontend/tests/setup-wasm.ts` reads both files
from disk and evaluates `wasm_exec.js` via Node's
`vm.runInThisContext`. The result is the same `Core` shape; the
JS-side bridging is identical.
## Field-name convention
The TS `Core` interface uses **camelCase** keys (matching the rest
of the SvelteKit / TS-side conventions). The Go bridge in
`ui/wasm/main.go` reads the same camelCase keys (`protocolVersion`,
`deviceSessionId`, etc.) directly from the JS objects via
`syscall/js`. There is no snake-case translation layer.
## Byte marshalling
The bridge avoids `js.CopyBytesToGo` and instead does an explicit
per-element copy via `value.Index(i).Int()`. TinyGo 0.41's
`js.CopyBytesToGo` panics on `Uint8Array` instances that originate
from Node's `Buffer` (which surfaces in Vitest's JSDOM environment
when fixtures are read with `crypto.createHash`). The per-element
loop is slower in theory but irrelevant in practice: the canonical
envelope payloads stay below a few hundred bytes.
## Reproducibility
TinyGo builds are not bit-for-bit deterministic (the binary embeds
build-machine identifiers). Treat the committed `core.wasm` as a
snapshot rebuilt by `make wasm` whenever `ui/core/` or
`ui/wasm/main.go` changes. CI rebuilds the artefact from source for
its own asserts; the committed copy keeps Vitest from depending on
TinyGo being installed in every environment.
## Bundle size
| Build | Date | Size |
|--------------|------------|-------|
| Phase 5 land | 2026-05-07 | 903 KB |
If the artefact ever crosses the 1 MB target, profile via
`tinygo build -size full` and trim before committing.