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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user