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).