// Command wasm is the TinyGo WebAssembly entry point for the Galaxy UI // client. It exposes a small "compute boundary" API on // `globalThis.galaxyCore` so the TypeScript-side `WasmCore` adapter can // call into the Go canonical-bytes serializer and signature verifier // without duplicating the contract in JavaScript. // // Public surface (all functions live under `globalThis.galaxyCore`): // // - signRequest(fields) -> Uint8Array // Returns the canonical bytes for a v1 request envelope. The actual // Ed25519 signing happens outside WASM (Phase 6 introduces WebCrypto // with non-exportable keys). // - verifyResponse(publicKey, signature, fields) -> boolean // - verifyEvent(publicKey, signature, fields) -> boolean // - verifyPayloadHash(payloadBytes, payloadHash) -> boolean // // Phase 18 adds the ship-math bridge over `pkg/calc/ship.go`. Each // function is a thin wrapper around the same-named upstream calc // function (zero math here, the bridge only marshals JS objects): // // - driveEffective(fields) -> number // - emptyMass(fields) -> number | null (null when invalid) // - weaponsBlockMass(fields) -> number | null (null when invalid) // - fullMass(fields) -> number // - speed(fields) -> number // - cargoCapacity(fields) -> number // - carryingMass(fields) -> number // - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview) // // Field objects are plain JS objects with camelCase keys matching the // TypeScript `Core` interface, and bytes fields are Uint8Array. // Timestamps are JS Number (Unix milliseconds fit in 53 bits well past // year 2200). // // All functions return either a Uint8Array, a number, a boolean, null, // or fail closed. They never throw — callers may inspect the result // or rely on the canon-byte length to detect malformed input. //go:build js && wasm package main import ( "syscall/js" "galaxy/core/calc" "galaxy/core/canon" ) func main() { js.Global().Set("galaxyCore", js.ValueOf(map[string]any{ "signRequest": js.FuncOf(signRequest), "verifyResponse": js.FuncOf(verifyResponse), "verifyEvent": js.FuncOf(verifyEvent), "verifyPayloadHash": js.FuncOf(verifyPayloadHash), "driveEffective": js.FuncOf(driveEffective), "emptyMass": js.FuncOf(emptyMass), "weaponsBlockMass": js.FuncOf(weaponsBlockMass), "fullMass": js.FuncOf(fullMass), "speed": js.FuncOf(speed), "cargoCapacity": js.FuncOf(cargoCapacity), "carryingMass": js.FuncOf(carryingMass), "blockUpgradeCost": js.FuncOf(blockUpgradeCost), })) // Block forever so the Go runtime stays alive while JS keeps calling // the registered functions. select {} } func signRequest(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } fields := canon.RequestSigningFields{ ProtocolVersion: args[0].Get("protocolVersion").String(), DeviceSessionID: args[0].Get("deviceSessionId").String(), MessageType: args[0].Get("messageType").String(), TimestampMS: int64(args[0].Get("timestampMs").Float()), RequestID: args[0].Get("requestId").String(), PayloadHash: copyBytesFromJS(args[0].Get("payloadHash")), } return copyBytesToJS(canon.BuildRequestSigningInput(fields)) } func verifyResponse(_ js.Value, args []js.Value) any { if len(args) != 3 { return js.ValueOf(false) } fields := canon.ResponseSigningFields{ ProtocolVersion: args[2].Get("protocolVersion").String(), RequestID: args[2].Get("requestId").String(), TimestampMS: int64(args[2].Get("timestampMs").Float()), ResultCode: args[2].Get("resultCode").String(), PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")), } publicKey := copyBytesFromJS(args[0]) signature := copyBytesFromJS(args[1]) if err := canon.VerifyResponseSignature(publicKey, signature, fields); err != nil { return js.ValueOf(false) } return js.ValueOf(true) } func verifyEvent(_ js.Value, args []js.Value) any { if len(args) != 3 { return js.ValueOf(false) } fields := canon.EventSigningFields{ EventType: args[2].Get("eventType").String(), EventID: args[2].Get("eventId").String(), TimestampMS: int64(args[2].Get("timestampMs").Float()), RequestID: args[2].Get("requestId").String(), TraceID: args[2].Get("traceId").String(), PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")), } publicKey := copyBytesFromJS(args[0]) signature := copyBytesFromJS(args[1]) if err := canon.VerifyEventSignature(publicKey, signature, fields); err != nil { return js.ValueOf(false) } return js.ValueOf(true) } func verifyPayloadHash(_ js.Value, args []js.Value) any { if len(args) != 2 { return js.ValueOf(false) } payloadBytes := copyBytesFromJS(args[0]) payloadHash := copyBytesFromJS(args[1]) if err := canon.VerifyPayloadHash(payloadBytes, payloadHash); err != nil { return js.ValueOf(false) } return js.ValueOf(true) } // driveEffective bridges `calc.DriveEffective`. Input // `{ drive, driveTech }`, output a JS number. func driveEffective(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } drive := args[0].Get("drive").Float() driveTech := args[0].Get("driveTech").Float() return js.ValueOf(calc.DriveEffective(drive, driveTech)) } // emptyMass bridges `calc.EmptyMass`. Input // `{ drive, weapons, armament, shields, cargo }`, output a JS number // or null when the upstream validator rejects the weapons/armament // pairing. func emptyMass(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } drive := args[0].Get("drive").Float() weapons := args[0].Get("weapons").Float() armament := uint(args[0].Get("armament").Int()) shields := args[0].Get("shields").Float() cargo := args[0].Get("cargo").Float() mass, ok := calc.EmptyMass(drive, weapons, armament, shields, cargo) if !ok { return js.Null() } return js.ValueOf(mass) } // weaponsBlockMass bridges `calc.WeaponsBlockMass`. Input // `{ weapons, armament }`, output a JS number or null on the same // invalid pairing as emptyMass. func weaponsBlockMass(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } weapons := args[0].Get("weapons").Float() armament := uint(args[0].Get("armament").Int()) mass, ok := calc.WeaponsBlockMass(weapons, armament) if !ok { return js.Null() } return js.ValueOf(mass) } // fullMass bridges `calc.FullMass`. Input // `{ emptyMass, carryingMass }`, output a JS number. func fullMass(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } em := args[0].Get("emptyMass").Float() cm := args[0].Get("carryingMass").Float() return js.ValueOf(calc.FullMass(em, cm)) } // speed bridges `calc.Speed`. Input `{ driveEffective, fullMass }`, // output a JS number (zero when fullMass is non-positive). func speed(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } de := args[0].Get("driveEffective").Float() fm := args[0].Get("fullMass").Float() return js.ValueOf(calc.Speed(de, fm)) } // cargoCapacity bridges `calc.CargoCapacity`. Input // `{ cargo, cargoTech }`, output a JS number (cargo units of hold). func cargoCapacity(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } cargo := args[0].Get("cargo").Float() cargoTech := args[0].Get("cargoTech").Float() return js.ValueOf(calc.CargoCapacity(cargo, cargoTech)) } // carryingMass bridges `calc.CarryingMass`. Input // `{ load, cargoTech }`, output a JS number (mass of `load` cargo // units at the player's cargo tech). func carryingMass(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } load := args[0].Get("load").Float() cargoTech := args[0].Get("cargoTech").Float() return js.ValueOf(calc.CarryingMass(load, cargoTech)) } // blockUpgradeCost bridges `calc.BlockUpgradeCost`. Input // `{ blockMass, currentTech, targetTech }`, output a JS number // (production cost of moving one block from currentTech to // targetTech; zero when blockMass is zero or targetTech is not // above currentTech). func blockUpgradeCost(_ js.Value, args []js.Value) any { if len(args) != 1 { return js.Null() } blockMass := args[0].Get("blockMass").Float() currentTech := args[0].Get("currentTech").Float() targetTech := args[0].Get("targetTech").Float() return js.ValueOf(calc.BlockUpgradeCost(blockMass, currentTech, targetTech)) } // copyBytesFromJS materialises a JS Uint8Array (or any indexable // byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo` // because TinyGo's implementation panics on values it does not // recognise as Uint8Array — a check that misfires for Uint8Arrays // constructed from Node's Buffer in Vitest's jsdom environment. The // per-element copy is slower but always correct, and the canonical // envelope payloads are small enough (≤ a few hundred bytes) that the // difference is negligible. func copyBytesFromJS(value js.Value) []byte { if value.IsUndefined() || value.IsNull() { return nil } length := value.Length() if length == 0 { return nil } dst := make([]byte, length) for i := 0; i < length; i++ { dst[i] = byte(value.Index(i).Int()) } return dst } // copyBytesToJS allocates a JS Uint8Array of the same length as src and // copies src into it. The result is safe to hand back across the // JS/Go boundary as the canonical-bytes return value. func copyBytesToJS(src []byte) js.Value { dst := js.Global().Get("Uint8Array").New(len(src)) js.CopyBytesToJS(dst, src) return dst }