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=