fix: update build-wasm target to use cmd/relay-server/dist directory

rabbitprincess committed Nov 17, 2025 at 00:07 UTC a4db15f8e4c9361ef69542d0d8a531af54e904c5
6 files changed +95 -134
.gitignore
+3 -1
@@ -8,9 +8,11 @@
8 *.so
9 *.dylib
10 bin/
11 -dist/
11 chat
12
13 +cmd/relay-server/dist/*
14 +!cmd/relay-server/dist/.gitkeep
15 +
16 # Test binary, built with `go test -c`
17 *.test
18 *.wasm
Dockerfile
-3
@@ -31,9 +31,6 @@ FROM gcr.io/distroless/static-debian12:nonroot
31 # Copy server binary
32 COPY --from=builder /src/bin/relay-server /usr/bin/relay-server
33
34 -# Copy static files for portal frontend
35 -COPY --from=builder /src/dist /app/dist
36 -
34 # Set default environment variables
35 ENV STATIC_DIR=/app/dist
36 ENV PORTAL_UI_URL=http://localhost:4017
Makefile
+15 -18
@@ -20,14 +20,12 @@ build-protoc:
20 # Build WASM artifacts with wasm-opt optimization and generate manifest
21 build-wasm:
22 @echo "[wasm] building webclient WASM..."
23 - @mkdir -p dist
24 -
25 - GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o dist/portal.wasm ./cmd/webclient
23 + GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o cmd/relay-server/dist/portal.wasm ./cmd/webclient
24
25 @echo "[wasm] optimizing with wasm-opt..."
26 @if command -v wasm-opt >/dev/null 2>&1; then \
29 - wasm-opt -Oz --enable-bulk-memory dist/portal.wasm -o dist/portal.wasm.tmp && \
30 - mv dist/portal.wasm.tmp dist/portal.wasm; \
27 + wasm-opt -Oz --enable-bulk-memory cmd/relay-server/dist/portal.wasm -o cmd/relay-server/dist/portal.wasm.tmp && \
28 + mv cmd/relay-server/dist/portal.wasm.tmp cmd/relay-server/dist/portal.wasm; \
29 echo "[wasm] optimization complete"; \
30 else \
31 echo "[wasm] WARNING: wasm-opt not found, skipping optimization"; \
@@ -35,28 +33,28 @@ build-wasm:
33 fi
34
35 @echo "[wasm] calculating SHA256 hash..."
38 - @WASM_HASH=$$(shasum -a 256 dist/portal.wasm | awk '{print $$1}'); \
36 + @WASM_HASH=$$(shasum -a 256 cmd/relay-server/dist/portal.wasm | awk '{print $$1}'); \
37 echo "[wasm] SHA256: $$WASM_HASH"; \
38 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; \
39 + find cmd/relay-server/dist -name '[0-9a-f]*.wasm' ! -name "$$WASM_HASH.wasm" -type f -delete 2>/dev/null || true; \
40 + cp cmd/relay-server/dist/portal.wasm cmd/relay-server/dist/$$WASM_HASH.wasm; \
41 + rm -f cmd/relay-server/dist/portal.wasm; \
42 echo "[wasm] content-addressed WASM: $$WASM_HASH.wasm"
43
44 @echo "[wasm] copying additional resources..."
47 - @cp cmd/webclient/wasm_exec.js dist/wasm_exec.js
48 - @cp cmd/webclient/service-worker.js dist/service-worker.js
49 - @cp cmd/webclient/index.html dist/portal.html
50 - @cp cmd/webclient/portal.mp4 dist/portal.mp4
45 + @cp cmd/webclient/wasm_exec.js cmd/relay-server/dist/wasm_exec.js
46 + @cp cmd/webclient/service-worker.js cmd/relay-server/dist/service-worker.js
47 + @cp cmd/webclient/index.html cmd/relay-server/dist/portal.html
48 + @cp cmd/webclient/portal.mp4 cmd/relay-server/dist/portal.mp4
49
50 @echo "[wasm] build complete"
51
52 # Precompress content-addressed WASM with brotli
53 compress-wasm:
54 @echo "[wasm] precompressing webclient WASM with brotli..."
57 - @WASM_FILE=$$(ls dist/[0-9a-f]*.wasm 2>/dev/null | head -n1); \
55 + @WASM_FILE=$$(ls cmd/relay-server/dist/[0-9a-f]*.wasm 2>/dev/null | head -n1); \
56 if [ -z "$$WASM_FILE" ]; then \
59 - echo "[wasm] ERROR: no content-addressed WASM found in dist; run build-wasm first"; \
57 + echo "[wasm] ERROR: no content-addressed WASM found in cmd/relay-server/dist; run build-wasm first"; \
58 exit 1; \
59 fi; \
60 WASM_HASH=$$(basename "$$WASM_FILE" .wasm); \
@@ -64,9 +62,9 @@ compress-wasm:
62 echo "[wasm] ERROR: brotli not found; install brotli to build compressed WASM"; \
63 exit 1; \
64 fi; \
67 - brotli -f "$$WASM_FILE" -o "dist/$$WASM_HASH.wasm.br"; \
65 + brotli -f "$$WASM_FILE" -o "cmd/relay-server/dist/$$WASM_HASH.wasm.br"; \
66 rm -f "$$WASM_FILE"; \
69 - echo "[wasm] brotli: dist/$$WASM_HASH.wasm.br (original removed)"
67 + echo "[wasm] brotli: cmd/relay-server/dist/$$WASM_HASH.wasm.br"
68
69 # Build Go relay server (embeds WASM from cmd/relay-server/static)
70 build-server:
@@ -80,4 +78,3 @@ build-tunnel:
78
79 clean:
80 rm -rf bin
83 - rm -rf dist
cmd/relay-server/frontend.go
+76 -102
@@ -1,11 +1,11 @@
1 package main
2
3 import (
4 + "embed"
5 "encoding/json"
6 "fmt"
7 "net/http"
7 - "os"
8 - "path/filepath"
8 + pathpkg "path"
9 "strconv"
10 "strings"
11 "sync"
@@ -13,8 +13,8 @@ import (
13 "github.com/rs/zerolog/log"
14 )
15
16 -// staticDir is the directory where static files are located
17 -var staticDir = "./dist"
16 +//go:embed dist/*
17 +var wasmFS embed.FS
18
19 // portalHost is the host for portal frontend.
20 var portalHost = "localhost"
@@ -40,10 +40,10 @@ var (
40 wasmCacheMu sync.RWMutex
41 )
42
43 -// initWasmCache loads pre-built WASM artifacts (original + precompressed) into memory on startup.
43 +// initWasmCache loads pre-built WASM artifacts (precompressed) into memory on startup.
44 func initWasmCache() error {
45 - // Read all files in staticDir
46 - entries, err := os.ReadDir(staticDir)
45 + // Read all files in embedded dist directory
46 + entries, err := wasmFS.ReadDir("dist")
47 if err != nil {
48 return err
49 }
@@ -54,15 +54,18 @@ func initWasmCache() error {
54 }
55
56 name := entry.Name()
57 - // Look for content-addressed WASM files: <64-char-hex>.wasm
58 - if strings.HasSuffix(name, ".wasm") && len(name) == 69 { // 64 + len(".wasm")
59 - hash := strings.TrimSuffix(name, ".wasm")
57 + // Look for content-addressed WASM files: <64-char-hex>.wasm.br
58 + if strings.HasSuffix(name, ".wasm.br") && len(name) == 72 { // 64 + len(".wasm.br")
59 + hash := strings.TrimSuffix(name, ".wasm.br")
60 if isHexString(hash) && len(hash) == 64 {
61 - fullPath := filepath.Join(staticDir, name)
62 - if err := cacheWasmFile(name, fullPath); err != nil {
61 + fullPath := pathpkg.Join("dist", name)
62 + // Cache under the URL path (<hash>.wasm) while reading the
63 + // brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
64 + cacheKey := hash + ".wasm"
65 + if err := cacheWasmFile(cacheKey, fullPath); err != nil {
66 log.Warn().Err(err).Str("file", name).Msg("failed to cache WASM file")
67 } else {
65 - log.Info().Str("file", name).Msg("cached WASM file")
68 + log.Info().Str("file", cacheKey).Msg("cached WASM file")
69 }
70 }
71 }
@@ -71,31 +74,25 @@ func initWasmCache() error {
74 return nil
75 }
76
74 -// cacheWasmFile reads and caches a WASM file and its pre-compressed variants (if present).
77 +// cacheWasmFile reads and caches a WASM file and its pre-compressed variant (brotli).
78 func cacheWasmFile(name, fullPath string) error {
76 - // Read original file
77 - original, err := os.ReadFile(fullPath)
78 - if err != nil {
79 - return err
80 - }
81 -
82 - // Verify SHA256 hash matches filename
79 + // Verify SHA256 hash matches filename (name is <hash>.wasm).
80 hashHex := strings.TrimSuffix(name, ".wasm")
81 if !isHexString(hashHex) || len(hashHex) != 64 {
82 log.Warn().Str("file", name).Msg("WASM file name is not a valid SHA256 hex string")
83 }
84
88 - // Load precompressed variant if it exists
85 + // Load precompressed variant (brotli) from embed.FS (<hash>.wasm.br)
86 var brData []byte
90 - brPath := fullPath + ".br"
91 - if data, err := os.ReadFile(brPath); err == nil {
87 + data, err := wasmFS.ReadFile(fullPath)
88 + if err != nil {
89 + log.Warn().Err(err).Str("file", fullPath).Msg("failed to read brotli-compressed WASM")
90 + } else {
91 brData = data
93 - } else if !os.IsNotExist(err) {
94 - log.Warn().Err(err).Str("file", brPath).Msg("failed to read brotli-compressed WASM")
92 }
93
94 entry := &wasmCacheEntry{
98 - original: original,
95 + original: nil,
96 brotli: brData,
97 hash: hashHex,
98 }
@@ -106,7 +103,6 @@ func cacheWasmFile(name, fullPath string) error {
103
104 log.Debug().
105 Str("file", name).
109 - Int("original", len(original)).
106 Int("brotli", len(entry.brotli)).
107 Msg("WASM file cached")
108
@@ -178,29 +174,29 @@ func createPortalMux() *http.ServeMux {
174 }
175
176 // servePortalStaticFile serves static files for portal frontend with caching
181 -func servePortalStaticFile(w http.ResponseWriter, r *http.Request, path string) {
177 +func servePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
178 // Check if this is a content-addressed WASM file
183 - if strings.HasSuffix(path, ".wasm") && len(path) == 69 { // 64 + len(".wasm")
184 - hash := strings.TrimSuffix(path, ".wasm")
179 + if strings.HasSuffix(filePath, ".wasm") && len(filePath) == 69 { // 64 + len(".wasm")
180 + hash := strings.TrimSuffix(filePath, ".wasm")
181 if isHexString(hash) && len(hash) == 64 {
186 - serveCompressedWasm(w, r, path)
182 + serveCompressedWasm(w, r, filePath)
183 return
184 }
185 }
186
187 // Regular static file serving
188 w.Header().Set("Cache-Control", "public, max-age=3600")
193 - serveStaticFile(w, r, path, "")
189 + serveStaticFile(w, r, filePath, "")
190 }
191
192 // serveCompressedWasm serves pre-compressed WASM files from memory cache
197 -func serveCompressedWasm(w http.ResponseWriter, r *http.Request, path string) {
193 +func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
194 wasmCacheMu.RLock()
199 - entry, ok := wasmCache[path]
195 + entry, ok := wasmCache[filePath]
196 wasmCacheMu.RUnlock()
197
198 if !ok {
203 - log.Debug().Str("path", path).Msg("WASM file not in cache")
199 + log.Debug().Str("path", filePath).Msg("WASM file not in cache")
200 http.NotFound(w, r)
201 return
202 }
@@ -210,32 +206,28 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, path string) {
206 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
207 w.Header().Set("Content-Type", "application/wasm")
208
213 - // Check Accept-Encoding header for compression support
209 + // Check Accept-Encoding header for brotli support
210 acceptEncoding := r.Header.Get("Accept-Encoding")
211
216 - // Prefer brotli if supported
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)
221 - w.Write(entry.brotli)
222 - log.Debug().
223 - Str("path", path).
224 - Int("size", len(entry.brotli)).
225 - Str("encoding", "brotli").
226 - Msg("served compressed WASM")
212 + // Require brotli-compressed WASM
213 + if !strings.Contains(acceptEncoding, "br") || len(entry.brotli) == 0 {
214 + log.Warn().
215 + Str("path", filePath).
216 + Str("acceptEncoding", acceptEncoding).
217 + Msg("client does not support brotli or brotli variant missing for WASM")
218 + http.Error(w, "brotli-compressed WASM required", http.StatusNotAcceptable)
219 return
220 }
221
230 - // No compression support - serve original
231 - w.Header().Set("Content-Length", strconv.Itoa(len(entry.original)))
222 + w.Header().Set("Content-Encoding", "br")
223 + w.Header().Set("Content-Length", strconv.Itoa(len(entry.brotli)))
224 w.WriteHeader(http.StatusOK)
233 - w.Write(entry.original)
225 + w.Write(entry.brotli)
226 log.Debug().
235 - Str("path", path).
236 - Int("size", len(entry.original)).
237 - Str("encoding", "none").
238 - Msg("served uncompressed WASM")
227 + Str("path", filePath).
228 + Int("size", len(entry.brotli)).
229 + Str("encoding", "brotli").
230 + Msg("served compressed WASM")
231 }
232
233 // serveAdminStatic serves static files for admin UI from embedded FS
@@ -249,7 +241,7 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, path string) {
241 // Try to read from embedded FS
242 setCORSHeaders(w)
243
252 - fullPath := filepath.Join("static", path)
244 + fullPath := pathpkg.Join("static", path)
245 data, err := assetsFS.ReadFile(fullPath)
246 if err != nil {
247 log.Debug().Err(err).Str("path", path).Msg("admin static file not found")
@@ -258,7 +250,7 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, path string) {
250 }
251
252 // Set content type based on extension
261 - ext := filepath.Ext(path)
253 + ext := pathpkg.Ext(path)
254 contentType := getContentType(ext)
255 if contentType != "" {
256 w.Header().Set("Content-Type", contentType)
@@ -319,41 +311,32 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
311 func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, contentType string) {
312 setCORSHeaders(w)
313
322 - fullPath := filepath.Join(staticDir, path)
323 -
324 - file, err := os.Open(fullPath)
314 + fullPath := pathpkg.Join("dist", path)
315 + data, err := wasmFS.ReadFile(fullPath)
316 if err != nil {
317 log.Debug().Err(err).Str("path", path).Msg("static file not found")
318 http.NotFound(w, r)
319 return
320 }
330 - defer file.Close()
331 -
332 - fileInfo, err := file.Stat()
333 - if err != nil {
334 - log.Error().Err(err).Str("path", path).Msg("failed to stat file")
335 - http.Error(w, "Internal server error", http.StatusInternalServerError)
336 - return
337 - }
321
322 // Set content type
323 if contentType != "" {
324 w.Header().Set("Content-Type", contentType)
325 } else {
343 - ext := filepath.Ext(path)
326 + ext := pathpkg.Ext(path)
327 ct := getContentType(ext)
328 if ct != "" {
329 w.Header().Set("Content-Type", ct)
330 }
331 }
332
350 - // Use http.ServeContent for proper Range request support (required for Safari video playback)
351 - http.ServeContent(w, r, path, fileInfo.ModTime(), file)
352 -
333 log.Debug().
334 Str("path", path).
355 - Int64("size", fileInfo.Size()).
335 + Int("size", len(data)).
336 Msg("served static file")
337 +
338 + w.WriteHeader(http.StatusOK)
339 + w.Write(data)
340 }
341
342 // serveStaticFileWithFallback reads and serves a file from the static directory
@@ -361,9 +344,8 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, conten
344 func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path string, contentType string) {
345 setCORSHeaders(w)
346
364 - fullPath := filepath.Join(staticDir, path)
365 -
366 - file, err := os.Open(fullPath)
347 + fullPath := pathpkg.Join("dist", path)
348 + data, err := wasmFS.ReadFile(fullPath)
349 if err != nil {
350 // File not found - fallback to portal.html for SPA routing
351 log.Debug().Err(err).Str("path", path).Msg("static file not found, serving portal.html")
@@ -371,33 +353,25 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path st
353 serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
354 return
355 }
374 - defer file.Close()
375 -
376 - fileInfo, err := file.Stat()
377 - if err != nil {
378 - log.Error().Err(err).Str("path", path).Msg("failed to stat file")
379 - http.Error(w, "Internal server error", http.StatusInternalServerError)
380 - return
381 - }
356
357 // Set content type
358 if contentType != "" {
359 w.Header().Set("Content-Type", contentType)
360 } else {
387 - ext := filepath.Ext(path)
361 + ext := pathpkg.Ext(path)
362 ct := getContentType(ext)
363 if ct != "" {
364 w.Header().Set("Content-Type", ct)
365 }
366 }
367
394 - // Use http.ServeContent for proper Range request support (required for Safari video playback)
395 - http.ServeContent(w, r, path, fileInfo.ModTime(), file)
396 -
368 log.Debug().
369 Str("path", path).
399 - Int64("size", fileInfo.Size()).
370 + Int("size", len(data)).
371 Msg("served static file")
372 +
373 + w.WriteHeader(http.StatusOK)
374 + w.Write(data)
375 }
376
377 // getContentType returns the MIME type for a file extension
@@ -477,21 +451,21 @@ func serveDynamicManifest(w http.ResponseWriter, r *http.Request) {
451 }
452 wasmCacheMu.RUnlock()
453
480 - // Fallback: scan directory if cache is empty
454 + // Fallback: scan embedded WASM directory if cache is empty
455 if wasmHash == "" {
482 - entries, err := os.ReadDir(staticDir)
456 + entries, err := wasmFS.ReadDir("dist")
457 if err == nil {
458 for _, entry := range entries {
459 if entry.IsDir() {
460 continue
461 }
462 name := entry.Name()
489 - // Look for content-addressed WASM files: <64-char-hex>.wasm
490 - if strings.HasSuffix(name, ".wasm") && len(name) == 69 {
491 - hash := strings.TrimSuffix(name, ".wasm")
463 + // Look for content-addressed WASM files: <64-char-hex>.wasm.br
464 + if strings.HasSuffix(name, ".wasm.br") && len(name) == 72 {
465 + hash := strings.TrimSuffix(name, ".wasm.br")
466 if isHexString(hash) && len(hash) == 64 {
467 wasmHash = hash
494 - wasmFile = name
468 + wasmFile = hash + ".wasm"
469 break
470 }
471 }
@@ -535,8 +509,8 @@ func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
509 setCORSHeaders(w)
510
511 // Read the service-worker.js template
538 - fullPath := filepath.Join(staticDir, "service-worker.js")
539 - content, err := os.ReadFile(fullPath)
512 + fullPath := pathpkg.Join("dist", "service-worker.js")
513 + content, err := wasmFS.ReadFile(fullPath)
514 if err != nil {
515 log.Error().Err(err).Msg("Failed to read service-worker.js")
516 http.NotFound(w, r)
@@ -554,20 +528,20 @@ func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
528 }
529 wasmCacheMu.RUnlock()
530
557 - // Fallback: scan directory if cache is empty
531 + // Fallback: scan embedded WASM directory if cache is empty
532 if wasmHash == "" {
559 - entries, err := os.ReadDir(staticDir)
533 + entries, err := wasmFS.ReadDir("dist")
534 if err == nil {
535 for _, entry := range entries {
536 if entry.IsDir() {
537 continue
538 }
539 name := entry.Name()
566 - if strings.HasSuffix(name, ".wasm") && len(name) == 69 {
567 - hash := strings.TrimSuffix(name, ".wasm")
540 + if strings.HasSuffix(name, ".wasm.br") && len(name) == 72 {
541 + hash := strings.TrimSuffix(name, ".wasm.br")
542 if isHexString(hash) && len(hash) == 64 {
543 wasmHash = hash
570 - wasmFile = name
544 + wasmFile = hash + ".wasm"
545 break
546 }
547 }
cmd/relay-server/main.go
+1 -10
@@ -21,7 +21,6 @@ var (
21 flagBootstraps []string
22 flagALPN string
23 flagPort int
24 - flagStaticDir string
24 flagPortalHost string
25 flagMaxLease int
26 flagLeaseBPS int
@@ -30,11 +29,6 @@ var (
29 func main() {
30 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
31
33 - // Defaults from environment
34 - defaultStaticDir := os.Getenv("STATIC_DIR")
35 - if defaultStaticDir == "" {
36 - defaultStaticDir = "./dist"
37 - }
32 // Parse PORTAL_UI_URL or PORTAL_FRONTEND_URL to extract portal host
33 defaultPortalHost := os.Getenv("PORTAL_UI_URL")
34 if defaultPortalHost == "" {
@@ -56,7 +50,6 @@ func main() {
50 flag.StringVar(&flagBootstrapsCSV, "bootstraps", defaultBootstraps, "bootstrap addresses (comma-separated)")
51 flag.StringVar(&flagALPN, "alpn", "http/1.1", "ALPN identifier for this service")
52 flag.IntVar(&flagPort, "port", 4017, "admin UI and HTTP proxy port")
59 - flag.StringVar(&flagStaticDir, "static-dir", defaultStaticDir, "static files directory for portal frontend (env: STATIC_DIR)")
53 flag.StringVar(&flagPortalHost, "portal-host", defaultPortalHost, "portal host for frontend serving (env: PORTAL_HOST)")
54 flag.IntVar(&flagMaxLease, "max-lease", 0, "maximum active relayed connections per lease (0 = unlimited)")
55 flag.IntVar(&flagLeaseBPS, "lease-bps", 0, "default bytes-per-second limit per lease (0 = unlimited)")
@@ -82,8 +75,7 @@ func runServer() error {
75 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
76 defer stop()
77
85 - // Set static directory and portal host
86 - staticDir = flagStaticDir
78 + // Set portal host
79 portalHost = flagPortalHost
80
81 // Set portal UI URL from environment or construct from portal host
@@ -114,7 +106,6 @@ func runServer() error {
106 }
107
108 log.Info().
117 - Str("static_dir", staticDir).
109 Str("portal_host", portalHost).
110 Str("portal_ui_url", portalUIURL).
111 Str("portal_frontend_pattern", portalFrontendPattern).
cmd/relay-server/static/dist/.gitkeep