feat: enhance WASM build process with brotli compression and update Makefile targets

Kim committed Nov 15, 2025 at 13:27 UTC 364a0928d8306fcd35e86cc464b2f4f8329fadcf
5 files changed +47 -85
Dockerfile
+8 -7
@@ -2,8 +2,12 @@ FROM golang:1 AS builder
2
3 WORKDIR /src
4
5 -# Install make and binaryen (for wasm-opt) to use Makefile
6 -RUN apt-get update && apt-get install -y --no-install-recommends binaryen make && rm -rf /var/lib/apt/lists/*
5 +# Install make, binaryen (wasm-opt), and brotli CLI for WASM build/compression
6 +RUN apt-get update && apt-get install -y --no-install-recommends \
7 + binaryen \
8 + brotli \
9 + make \
10 + && rm -rf /var/lib/apt/lists/*
11
12 COPY go.mod go.sum ./
13
@@ -13,11 +17,8 @@ RUN go mod download
17 # Copy the rest of the source code
18 COPY . .
19
16 -# Build WASM and server
17 -RUN make build-wasm
18 -
19 -# Build server
20 -RUN make build-server
20 +# Build WASM, precompress, and server
21 +RUN make build-wasm compress-wasm build-server
22
23 FROM gcr.io/distroless/static-debian12:nonroot
24
Makefile
+22 -4
@@ -1,12 +1,12 @@
1 SHELL := /bin/sh
2
3 -.PHONY: run build build-wasm build-server clean
3 +.PHONY: run build build-wasm compress-wasm build-server clean
4
5 run:
6 ./bin/relay-server
7
8 -# Convenience target: build wasm then server
9 -build: build-protoc build-wasm build-server build-tunnel
8 +# Convenience target: build wasm, compress, then server
9 +build: build-protoc build-wasm compress-wasm build-server build-tunnel
10
11 build-protoc:
12 protoc -I . \
@@ -40,6 +40,7 @@ build-wasm:
40 echo "[wasm] cleaning old hash files..."; \
41 find dist -name '[0-9a-f]*.wasm' ! -name "$$WASM_HASH.wasm" -type f -delete 2>/dev/null || true; \
42 cp dist/portal.wasm dist/$$WASM_HASH.wasm; \
43 + rm -f dist/portal.wasm; \
44 echo "[wasm] content-addressed WASM: $$WASM_HASH.wasm"
45
46 @echo "[wasm] copying additional resources..."
@@ -50,6 +51,23 @@ build-wasm:
51
52 @echo "[wasm] build complete"
53
54 +# Precompress content-addressed WASM with brotli
55 +compress-wasm:
56 + @echo "[wasm] precompressing webclient WASM with brotli..."
57 + @WASM_FILE=$$(ls dist/[0-9a-f]*.wasm 2>/dev/null | head -n1); \
58 + if [ -z "$$WASM_FILE" ]; then \
59 + echo "[wasm] ERROR: no content-addressed WASM found in dist; run build-wasm first"; \
60 + exit 1; \
61 + fi; \
62 + WASM_HASH=$$(basename "$$WASM_FILE" .wasm); \
63 + if ! command -v brotli >/dev/null 2>&1; then \
64 + echo "[wasm] ERROR: brotli not found; install brotli to build compressed WASM"; \
65 + exit 1; \
66 + fi; \
67 + brotli -f "$$WASM_FILE" -o "dist/$$WASM_HASH.wasm.br"; \
68 + rm -f "$$WASM_FILE"; \
69 + echo "[wasm] brotli: dist/$$WASM_HASH.wasm.br (original removed)"
70 +
71 # Build Go relay server (embeds WASM from cmd/relay-server/static)
72 build-server:
73 @echo "[server] building Go portal..."
@@ -62,4 +80,4 @@ build-tunnel:
80
81 clean:
82 rm -rf bin
65 - rm -rf dist
\ No newline at end of file
83 + rm -rf dist
cmd/relay-server/frontend.go
+17 -58
@@ -1,10 +1,6 @@
1 package main
2
3 import (
4 - "bytes"
5 - "compress/gzip"
6 - "crypto/sha256"
7 - "encoding/hex"
4 "encoding/json"
5 "fmt"
6 "net/http"
@@ -14,7 +10,6 @@ import (
10 "strings"
11 "sync"
12
17 - "github.com/andybalholm/brotli"
13 "github.com/rs/zerolog/log"
14 )
15
@@ -33,11 +28,10 @@ var portalFrontendPattern = ""
28 // bootstrapURIs stores the relay bootstrap server URIs
29 var bootstrapURIs = "ws://localhost:4017/relay"
30
36 -// wasmCache stores pre-compressed WASM files in memory
31 +// wasmCache stores pre-loaded WASM files in memory (optional)
32 type wasmCacheEntry struct {
33 original []byte
34 brotli []byte
40 - gzip []byte
35 hash string
36 }
37
@@ -46,7 +40,7 @@ var (
40 wasmCacheMu sync.RWMutex
41 )
42
49 -// initWasmCache pre-compresses and caches all WASM files on startup
43 +// initWasmCache loads pre-built WASM artifacts (original + precompressed) into memory on startup.
44 func initWasmCache() error {
45 // Read all files in staticDir
46 entries, err := os.ReadDir(staticDir)
@@ -68,7 +62,7 @@ func initWasmCache() error {
62 if err := cacheWasmFile(name, fullPath); err != nil {
63 log.Warn().Err(err).Str("file", name).Msg("failed to cache WASM file")
64 } else {
71 - log.Info().Str("file", name).Msg("cached and compressed WASM file")
65 + log.Info().Str("file", name).Msg("cached WASM file")
66 }
67 }
68 }
@@ -77,7 +71,7 @@ func initWasmCache() error {
71 return nil
72 }
73
80 -// cacheWasmFile reads, compresses, and caches a WASM file
74 +// cacheWasmFile reads and caches a WASM file and its pre-compressed variants (if present).
75 func cacheWasmFile(name, fullPath string) error {
76 // Read original file
77 original, err := os.ReadFile(fullPath)
@@ -86,42 +80,24 @@ func cacheWasmFile(name, fullPath string) error {
80 }
81
82 // Verify SHA256 hash matches filename
89 - hash := sha256.Sum256(original)
90 - expectedHash := strings.TrimSuffix(name, ".wasm")
91 - actualHash := hex.EncodeToString(hash[:])
92 - if expectedHash != actualHash {
93 - log.Warn().
94 - Str("file", name).
95 - Str("expected", expectedHash).
96 - Str("actual", actualHash).
97 - Msg("WASM file hash mismatch")
98 - }
99 -
100 - // Compress with brotli (level 11 for maximum compression)
101 - var brBuf bytes.Buffer
102 - brWriter := brotli.NewWriterLevel(&brBuf, 11)
103 - if _, err := brWriter.Write(original); err != nil {
104 - return err
105 - }
106 - if err := brWriter.Close(); err != nil {
107 - return err
83 + hashHex := strings.TrimSuffix(name, ".wasm")
84 + if !isHexString(hashHex) || len(hashHex) != 64 {
85 + log.Warn().Str("file", name).Msg("WASM file name is not a valid SHA256 hex string")
86 }
87
110 - // Compress with gzip as fallback
111 - var gzBuf bytes.Buffer
112 - gzWriter := gzip.NewWriter(&gzBuf)
113 - if _, err := gzWriter.Write(original); err != nil {
114 - return err
115 - }
116 - if err := gzWriter.Close(); err != nil {
117 - return err
88 + // Load precompressed variant if it exists
89 + var brData []byte
90 + brPath := fullPath + ".br"
91 + if data, err := os.ReadFile(brPath); err == nil {
92 + brData = data
93 + } else if !os.IsNotExist(err) {
94 + log.Warn().Err(err).Str("file", brPath).Msg("failed to read brotli-compressed WASM")
95 }
96
97 entry := &wasmCacheEntry{
98 original: original,
122 - brotli: brBuf.Bytes(),
123 - gzip: gzBuf.Bytes(),
124 - hash: actualHash,
99 + brotli: brData,
100 + hash: hashHex,
101 }
102
103 wasmCacheMu.Lock()
@@ -132,9 +108,6 @@ func cacheWasmFile(name, fullPath string) error {
108 Str("file", name).
109 Int("original", len(original)).
110 Int("brotli", len(entry.brotli)).
135 - Int("gzip", len(entry.gzip)).
136 - Float64("br_ratio", float64(len(entry.brotli))/float64(len(original))*100).
137 - Float64("gz_ratio", float64(len(entry.gzip))/float64(len(original))*100).
111 Msg("WASM file cached")
112
113 return nil
@@ -241,7 +214,7 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, path string) {
214 acceptEncoding := r.Header.Get("Accept-Encoding")
215
216 // Prefer brotli if supported
244 - if strings.Contains(acceptEncoding, "br") {
217 + if strings.Contains(acceptEncoding, "br") && len(entry.brotli) > 0 {
218 w.Header().Set("Content-Encoding", "br")
219 w.Header().Set("Content-Length", strconv.Itoa(len(entry.brotli)))
220 w.WriteHeader(http.StatusOK)
@@ -254,20 +227,6 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, path string) {
227 return
228 }
229
257 - // Fall back to gzip if supported
258 - if strings.Contains(acceptEncoding, "gzip") {
259 - w.Header().Set("Content-Encoding", "gzip")
260 - w.Header().Set("Content-Length", strconv.Itoa(len(entry.gzip)))
261 - w.WriteHeader(http.StatusOK)
262 - w.Write(entry.gzip)
263 - log.Debug().
264 - Str("path", path).
265 - Int("size", len(entry.gzip)).
266 - Str("encoding", "gzip").
267 - Msg("served compressed WASM")
268 - return
269 - }
270 -
230 // No compression support - serve original
231 w.Header().Set("Content-Length", strconv.Itoa(len(entry.original)))
232 w.WriteHeader(http.StatusOK)
go.mod
-1
@@ -3,7 +3,6 @@ module gosuda.org/portal
3 go 1.25.3
4
5 require (
6 - github.com/andybalholm/brotli v1.2.0
6 github.com/gorilla/websocket v1.5.3
7 github.com/hashicorp/yamux v0.1.2
8 github.com/planetscale/vtprotobuf v0.6.0
go.sum
-15
@@ -1,7 +1,4 @@
1 -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
2 -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
1 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4 -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
3 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -9,8 +6,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
6 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
7 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
12 -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
13 -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
9 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
10 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
11 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -24,16 +19,8 @@ github.com/planetscale/vtprotobuf v0.6.0/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6
19 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
20 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
21 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
27 -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
28 -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
29 -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
30 -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
31 -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
32 -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
22 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
23 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
35 -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
36 -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
24 golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
25 golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
26 golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
@@ -47,5 +34,3 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
34 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
35 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
36 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
50 -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
51 -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=