diff --git a/.gitea/actions/build-wasm/action.yml b/.gitea/actions/build-wasm/action.yml new file mode 100644 index 0000000..068b707 --- /dev/null +++ b/.gitea/actions/build-wasm/action.yml @@ -0,0 +1,33 @@ +name: Build core.wasm +description: >- + Install TinyGo (cached) and build ui/core to frontend/static/core.wasm + and wasm_exec.js via `make -C ui wasm`. The binaries are no longer + committed, so every workflow that builds or serves the frontend bundle + (ui-test, dev-deploy, prod-build) runs this first. Requires Go to be + set up by the caller — TinyGo shells out to the Go toolchain. + +runs: + using: composite + steps: + - name: Restore TinyGo cache + uses: actions/cache@v4 + with: + path: ~/.cache/galaxy-tinygo + key: tinygo-0.41.1-linux-amd64 + + - name: Install TinyGo + shell: bash + run: | + set -euo pipefail + version="0.41.1" + root="$HOME/.cache/galaxy-tinygo/tinygo" + if [ ! -x "$root/bin/tinygo" ]; then + mkdir -p "$HOME/.cache/galaxy-tinygo" + curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${version}/tinygo${version}.linux-amd64.tar.gz" \ + | tar -xz -C "$HOME/.cache/galaxy-tinygo" + fi + echo "$root/bin" >> "$GITHUB_PATH" + + - name: Build core.wasm + shell: bash + run: make -C ui wasm diff --git a/.gitea/workflows/dev-deploy.yaml b/.gitea/workflows/dev-deploy.yaml index 0aa4bab..e0c5249 100644 --- a/.gitea/workflows/dev-deploy.yaml +++ b/.gitea/workflows/dev-deploy.yaml @@ -70,6 +70,9 @@ jobs: working-directory: ui run: pnpm install --frozen-lockfile + - name: Build core.wasm + uses: ./.gitea/actions/build-wasm + - name: Build UI frontend working-directory: ui/frontend env: diff --git a/.gitea/workflows/prod-build.yaml b/.gitea/workflows/prod-build.yaml index 1f860c3..60dafad 100644 --- a/.gitea/workflows/prod-build.yaml +++ b/.gitea/workflows/prod-build.yaml @@ -87,6 +87,9 @@ jobs: working-directory: ui run: pnpm install --frozen-lockfile + - name: Build core.wasm + uses: ./.gitea/actions/build-wasm + - name: Build UI bundle working-directory: ui/frontend env: diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml index 6142c50..173b7bd 100644 --- a/.gitea/workflows/ui-test.yaml +++ b/.gitea/workflows/ui-test.yaml @@ -61,6 +61,15 @@ jobs: working-directory: ui run: pnpm install --frozen-lockfile + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: Build core.wasm + uses: ./.gitea/actions/build-wasm + - name: Install Playwright browsers # `--with-deps` would shell out to `sudo apt-get install` for # the system .so libraries, which the host-mode runner cannot diff --git a/ui/.gitignore b/ui/.gitignore index 6db088c..e916025 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -7,12 +7,11 @@ node_modules/ build/ dist/ -# Generated WASM bundles. The committed `frontend/static/core.wasm` -# (built by `make wasm` from `ui/wasm/`) is intentionally tracked so -# Vitest and the SvelteKit dev server have the artefact available -# without forcing every contributor to install TinyGo locally. +# Generated WASM bundle (built by `make wasm` from `ui/wasm/`). No longer +# committed: CI builds it (see `.gitea/actions/build-wasm`) and a local +# dev build runs `make -C ui wasm` once (see `ui/docs/wasm-toolchain.md`). *.wasm -!frontend/static/core.wasm +frontend/static/wasm_exec.js # Wails desktop wrapper (Phase 31+) desktop/build/ diff --git a/ui/PLAN-finalize.md b/ui/PLAN-finalize.md index 523555d..8911a7c 100644 --- a/ui/PLAN-finalize.md +++ b/ui/PLAN-finalize.md @@ -133,7 +133,19 @@ Acceptance: installs as a PWA on Chrome, Edge, and iOS Safari; the SW survives an app update without serving stale code. Tests: Lighthouse PWA ≥ 90; Playwright install→offline→cached-login; version-bump invalidation. -## F6 — Build hygiene: build core.wasm in CI +## F6 — Build hygiene: build core.wasm in CI — done + +`core.wasm` / `wasm_exec.js` are no longer committed (untracked + +gitignored). A reusable composite action +`.gitea/actions/build-wasm` installs TinyGo (`actions/cache`d) and runs +`make -C ui wasm`; it is invoked by **all three** frontend-building +workflows — `ui-test` (before Playwright; Vitest needs no build, it uses +the fake Core), `dev-deploy`, and `prod-build` (which build the bundle on +the runner via `pnpm build`, then package it). `ui-test` gained a Go +setup; the deploy workflows already had one. Docs: +`ui/docs/wasm-toolchain.md`, `ui/README.md`. + + (From the PLAN.md TODO; timely — the binary is currently committed and must be rebuilt by hand on every Go-bridge change, which has already diff --git a/ui/README.md b/ui/README.md index 985532d..31277ca 100644 --- a/ui/README.md +++ b/ui/README.md @@ -81,7 +81,7 @@ ui/ ├── src/platform/store/ KeyStore/Cache interfaces + web adapter ├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings ├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create) - └── static/ core.wasm + wasm_exec.js (committed artefacts) + └── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored) ``` Linked topic docs: diff --git a/ui/docs/wasm-toolchain.md b/ui/docs/wasm-toolchain.md index d8fa4f4..a23b2d0 100644 --- a/ui/docs/wasm-toolchain.md +++ b/ui/docs/wasm-toolchain.md @@ -98,11 +98,17 @@ 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. +build-machine identifiers), so `core.wasm` / `wasm_exec.js` are **not +committed**. CI builds them from source: the +[`.gitea/actions/build-wasm`](../../.gitea/actions/build-wasm/action.yml) +composite action installs TinyGo (cached) and runs `make -C ui wasm` +ahead of every step that builds or serves the frontend bundle — the +`ui-test` Playwright run, `dev-deploy`, and `prod-build`. + +For local work, run `make -C ui wasm` once after cloning, and again +whenever `ui/core/` or `ui/wasm/main.go` changes. Vitest needs no build +(it uses the fake Core in `tests/fake-core.ts`); only the SvelteKit dev +server and Playwright serve the real artefact from `frontend/static/`. ## Bundle size @@ -111,4 +117,4 @@ TinyGo being installed in every environment. | Initial 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. +`tinygo build -size full` and trim it. diff --git a/ui/frontend/static/core.wasm b/ui/frontend/static/core.wasm deleted file mode 100644 index 4e1c28e..0000000 Binary files a/ui/frontend/static/core.wasm and /dev/null differ diff --git a/ui/frontend/static/wasm_exec.js b/ui/frontend/static/wasm_exec.js deleted file mode 100644 index 3d926ce..0000000 --- a/ui/frontend/static/wasm_exec.js +++ /dev/null @@ -1,559 +0,0 @@ -// Copyright 2018 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. -// -// This file has been modified for use by the TinyGo compiler. - -(() => { - // Map multiple JavaScript environments to a single common API, - // preferring web standards over Node.js API. - // - // Environments considered: - // - Browsers - // - Node.js - // - Electron - // - Parcel - - if (typeof global !== "undefined") { - // global already exists - } else if (typeof window !== "undefined") { - window.global = window; - } else if (typeof self !== "undefined") { - self.global = self; - } else { - throw new Error("cannot export Go (neither global, window nor self is defined)"); - } - - if (!global.require && typeof require !== "undefined") { - global.require = require; - } - - if (!global.fs && global.require) { - global.fs = require("node:fs"); - } - - const enosys = () => { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; - }; - - if (!global.fs) { - let outputBuf = ""; - global.fs = { - constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused - writeSync(fd, buf) { - outputBuf += decoder.decode(buf); - const nl = outputBuf.lastIndexOf("\n"); - if (nl != -1) { - console.log(outputBuf.substr(0, nl)); - outputBuf = outputBuf.substr(nl + 1); - } - return buf.length; - }, - write(fd, buf, offset, length, position, callback) { - if (offset !== 0 || length !== buf.length || position !== null) { - callback(enosys()); - return; - } - const n = this.writeSync(fd, buf); - callback(null, n); - }, - chmod(path, mode, callback) { callback(enosys()); }, - chown(path, uid, gid, callback) { callback(enosys()); }, - close(fd, callback) { callback(enosys()); }, - fchmod(fd, mode, callback) { callback(enosys()); }, - fchown(fd, uid, gid, callback) { callback(enosys()); }, - fstat(fd, callback) { callback(enosys()); }, - fsync(fd, callback) { callback(null); }, - ftruncate(fd, length, callback) { callback(enosys()); }, - lchown(path, uid, gid, callback) { callback(enosys()); }, - link(path, link, callback) { callback(enosys()); }, - lstat(path, callback) { callback(enosys()); }, - mkdir(path, perm, callback) { callback(enosys()); }, - open(path, flags, mode, callback) { callback(enosys()); }, - read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, - readdir(path, callback) { callback(enosys()); }, - readlink(path, callback) { callback(enosys()); }, - rename(from, to, callback) { callback(enosys()); }, - rmdir(path, callback) { callback(enosys()); }, - stat(path, callback) { callback(enosys()); }, - symlink(path, link, callback) { callback(enosys()); }, - truncate(path, length, callback) { callback(enosys()); }, - unlink(path, callback) { callback(enosys()); }, - utimes(path, atime, mtime, callback) { callback(enosys()); }, - }; - } - - if (!global.process) { - global.process = { - getuid() { return -1; }, - getgid() { return -1; }, - geteuid() { return -1; }, - getegid() { return -1; }, - getgroups() { throw enosys(); }, - pid: -1, - ppid: -1, - umask() { throw enosys(); }, - cwd() { throw enosys(); }, - chdir() { throw enosys(); }, - } - } - - if (!global.crypto) { - const nodeCrypto = require("node:crypto"); - global.crypto = { - getRandomValues(b) { - nodeCrypto.randomFillSync(b); - }, - }; - } - - if (!global.performance) { - global.performance = { - now() { - const [sec, nsec] = process.hrtime(); - return sec * 1000 + nsec / 1000000; - }, - }; - } - - if (!global.TextEncoder) { - global.TextEncoder = require("node:util").TextEncoder; - } - - if (!global.TextDecoder) { - global.TextDecoder = require("node:util").TextDecoder; - } - - // End of polyfills for common API. - - const encoder = new TextEncoder("utf-8"); - const decoder = new TextDecoder("utf-8"); - let reinterpretBuf = new DataView(new ArrayBuffer(8)); - var logLine = []; - const wasmExit = {}; // thrown to exit via proc_exit (not an error) - - global.Go = class { - constructor() { - this._callbackTimeouts = new Map(); - this._nextCallbackTimeoutID = 1; - - const mem = () => { - // The buffer may change when requesting more memory. - return new DataView(this._inst.exports.memory.buffer); - } - - const unboxValue = (v_ref) => { - reinterpretBuf.setBigInt64(0, v_ref, true); - const f = reinterpretBuf.getFloat64(0, true); - if (f === 0) { - return undefined; - } - if (!isNaN(f)) { - return f; - } - - const id = v_ref & 0xffffffffn; - return this._values[id]; - } - - - const loadValue = (addr) => { - let v_ref = mem().getBigUint64(addr, true); - return unboxValue(v_ref); - } - - const boxValue = (v) => { - const nanHead = 0x7FF80000n; - - if (typeof v === "number") { - if (isNaN(v)) { - return nanHead << 32n; - } - if (v === 0) { - return (nanHead << 32n) | 1n; - } - reinterpretBuf.setFloat64(0, v, true); - return reinterpretBuf.getBigInt64(0, true); - } - - switch (v) { - case undefined: - return 0n; - case null: - return (nanHead << 32n) | 2n; - case true: - return (nanHead << 32n) | 3n; - case false: - return (nanHead << 32n) | 4n; - } - - let id = this._ids.get(v); - if (id === undefined) { - id = this._idPool.pop(); - if (id === undefined) { - id = BigInt(this._values.length); - } - this._values[id] = v; - this._goRefCounts[id] = 0; - this._ids.set(v, id); - } - this._goRefCounts[id]++; - let typeFlag = 1n; - switch (typeof v) { - case "string": - typeFlag = 2n; - break; - case "symbol": - typeFlag = 3n; - break; - case "function": - typeFlag = 4n; - break; - } - return id | ((nanHead | typeFlag) << 32n); - } - - const storeValue = (addr, v) => { - let v_ref = boxValue(v); - mem().setBigUint64(addr, v_ref, true); - } - - const loadSlice = (array, len, cap) => { - return new Uint8Array(this._inst.exports.memory.buffer, array, len); - } - - const loadSliceOfValues = (array, len, cap) => { - const a = new Array(len); - for (let i = 0; i < len; i++) { - a[i] = loadValue(array + i * 8); - } - return a; - } - - const loadString = (ptr, len) => { - return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len)); - } - - const timeOrigin = Date.now() - performance.now(); - const wasi_EBADF = 8; - const wasi_ENOSYS = 52; - this.importObject = { - wasi_snapshot_preview1: { - // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md - fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) { - let nwritten = 0; - if (fd == 1) { - for (let iovs_i=0; iovs_i wasi_ENOSYS, - fd_close: () => wasi_ENOSYS, - fd_fdstat_get: () => wasi_ENOSYS, - fd_prestat_get: () => wasi_EBADF, // wasi-libc relies on this errno value - fd_prestat_dir_name: () => wasi_ENOSYS, - fd_seek: () => wasi_ENOSYS, - path_open: () => wasi_ENOSYS, - proc_exit: (code) => { - this.exited = true; - this.exitCode = code; - this._resolveExitPromise(); - throw wasmExit; - }, - random_get: (bufPtr, bufLen) => { - crypto.getRandomValues(loadSlice(bufPtr, bufLen)); - return 0; - }, - }, - gojs: { - // func ticks() int64 - "runtime.ticks": () => { - return BigInt((timeOrigin + performance.now()) * 1e6); - }, - - // func sleepTicks(timeout int64) - "runtime.sleepTicks": (timeout) => { - // Do not sleep, only reactivate scheduler after the given timeout. - setTimeout(() => { - if (this.exited) return; - try { - this._inst.exports.go_scheduler(); - } catch (e) { - if (e !== wasmExit) throw e; - } - }, Number(timeout)/1e6); - }, - - // func finalizeRef(v ref) - "syscall/js.finalizeRef": (v_ref) => { - // Note: TinyGo does not support finalizers so this is only called - // for one specific case, by js.go:jsString. and can/might leak memory. - const id = v_ref & 0xffffffffn; - if (this._goRefCounts?.[id] !== undefined) { - this._goRefCounts[id]--; - if (this._goRefCounts[id] === 0) { - const v = this._values[id]; - this._values[id] = null; - this._ids.delete(v); - this._idPool.push(id); - } - } else { - console.error("syscall/js.finalizeRef: unknown id", id); - } - }, - - // func stringVal(value string) ref - "syscall/js.stringVal": (value_ptr, value_len) => { - value_ptr >>>= 0; - const s = loadString(value_ptr, value_len); - return boxValue(s); - }, - - // func valueGet(v ref, p string) ref - "syscall/js.valueGet": (v_ref, p_ptr, p_len) => { - let prop = loadString(p_ptr, p_len); - let v = unboxValue(v_ref); - let result = Reflect.get(v, prop); - return boxValue(result); - }, - - // func valueSet(v ref, p string, x ref) - "syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => { - const v = unboxValue(v_ref); - const p = loadString(p_ptr, p_len); - const x = unboxValue(x_ref); - Reflect.set(v, p, x); - }, - - // func valueDelete(v ref, p string) - "syscall/js.valueDelete": (v_ref, p_ptr, p_len) => { - const v = unboxValue(v_ref); - const p = loadString(p_ptr, p_len); - Reflect.deleteProperty(v, p); - }, - - // func valueIndex(v ref, i int) ref - "syscall/js.valueIndex": (v_ref, i) => { - return boxValue(Reflect.get(unboxValue(v_ref), i)); - }, - - // valueSetIndex(v ref, i int, x ref) - "syscall/js.valueSetIndex": (v_ref, i, x_ref) => { - Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref)); - }, - - // func valueCall(v ref, m string, args []ref) (ref, bool) - "syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => { - const v = unboxValue(v_ref); - const name = loadString(m_ptr, m_len); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - try { - const m = Reflect.get(v, name); - storeValue(ret_addr, Reflect.apply(m, v, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr + 8, 0); - } - }, - - // func valueInvoke(v ref, args []ref) (ref, bool) - "syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { - try { - const v = unboxValue(v_ref); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - storeValue(ret_addr, Reflect.apply(v, undefined, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr + 8, 0); - } - }, - - // func valueNew(v ref, args []ref) (ref, bool) - "syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { - const v = unboxValue(v_ref); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - try { - storeValue(ret_addr, Reflect.construct(v, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr+ 8, 0); - } - }, - - // func valueLength(v ref) int - "syscall/js.valueLength": (v_ref) => { - return unboxValue(v_ref).length; - }, - - // valuePrepareString(v ref) (ref, int) - "syscall/js.valuePrepareString": (ret_addr, v_ref) => { - const s = String(unboxValue(v_ref)); - const str = encoder.encode(s); - storeValue(ret_addr, str); - mem().setInt32(ret_addr + 8, str.length, true); - }, - - // valueLoadString(v ref, b []byte) - "syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => { - const str = unboxValue(v_ref); - loadSlice(slice_ptr, slice_len, slice_cap).set(str); - }, - - // func valueInstanceOf(v ref, t ref) bool - "syscall/js.valueInstanceOf": (v_ref, t_ref) => { - return unboxValue(v_ref) instanceof unboxValue(t_ref); - }, - - // func copyBytesToGo(dst []byte, src ref) (int, bool) - "syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => { - let num_bytes_copied_addr = ret_addr; - let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable - - const dst = loadSlice(dest_addr, dest_len); - const src = unboxValue(src_ref); - if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { - mem().setUint8(returned_status_addr, 0); // Return "not ok" status - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - mem().setUint32(num_bytes_copied_addr, toCopy.length, true); - mem().setUint8(returned_status_addr, 1); // Return "ok" status - }, - - // copyBytesToJS(dst ref, src []byte) (int, bool) - // Originally copied from upstream Go project, then modified: - // https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416 - "syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => { - let num_bytes_copied_addr = ret_addr; - let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable - - const dst = unboxValue(dst_ref); - const src = loadSlice(src_addr, src_len); - if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { - mem().setUint8(returned_status_addr, 0); // Return "not ok" status - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - mem().setUint32(num_bytes_copied_addr, toCopy.length, true); - mem().setUint8(returned_status_addr, 1); // Return "ok" status - }, - } - }; - - // Go 1.20 uses 'env'. Go 1.21 uses 'gojs'. - // For compatibility, we use both as long as Go 1.20 is supported. - this.importObject.env = this.importObject.gojs; - } - - async run(instance) { - this._inst = instance; - this._values = [ // JS values that Go currently has references to, indexed by reference id - NaN, - 0, - null, - true, - false, - global, - this, - ]; - this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id - this._ids = new Map(); // mapping from JS values to reference ids - this._idPool = []; // unused ids that have been garbage collected - this.exited = false; // whether the Go program has exited - this.exitCode = 0; - - if (this._inst.exports._start) { - let exitPromise = new Promise((resolve, reject) => { - this._resolveExitPromise = resolve; - }); - - // Run program, but catch the wasmExit exception that's thrown - // to return back here. - try { - this._inst.exports._start(); - } catch (e) { - if (e !== wasmExit) throw e; - } - - await exitPromise; - return this.exitCode; - } else { - this._inst.exports._initialize(); - } - } - - _resume() { - if (this.exited) { - throw new Error("Go program has already exited"); - } - try { - this._inst.exports.resume(); - } catch (e) { - if (e !== wasmExit) throw e; - } - if (this.exited) { - this._resolveExitPromise(); - } - } - - _makeFuncWrapper(id) { - const go = this; - return function () { - const event = { id: id, this: this, args: arguments }; - go._pendingEvent = event; - go._resume(); - return event.result; - }; - } - } - - if ( - global.require && - global.require.main === module && - global.process && - global.process.versions && - !global.process.versions.electron - ) { - if (process.argv.length != 3) { - console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); - process.exit(1); - } - - const go = new Go(); - WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => { - let exitCode = await go.run(result.instance); - process.exit(exitCode); - }).catch((err) => { - console.error(err); - process.exit(1); - }); - } -})();