relay-serer: simplify build process

Kim committed Nov 18, 2025 at 16:59 UTC 5f9ae1d51fb03cbd8b5737320bd4d530a76978b7
7 files changed +57 -102
.gitignore
+2 -4
@@ -10,10 +10,8 @@
10 bin/
11 chat
12
13 -cmd/relay-server/dist/*
14 -!cmd/relay-server/dist/.gitkeep
15 -cmd/relay-server/app/*
16 -!cmd/relay-server/app/.gitkeep
13 +cmd/relay-server/dist/app
14 +cmd/relay-server/dist/wasm
15
16 # Test binary, built with `go test -c`
17 *.test
Dockerfile
+5 -48
@@ -1,76 +1,33 @@
1 # syntax=docker/dockerfile:1
2
3 -# Frontend build stage
4 -FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend-builder
5 -
6 -WORKDIR /src
7 -
8 -# Copy frontend package files
9 -COPY cmd/relay-server/frontend/package*.json ./cmd/relay-server/frontend/
10 -
11 -# Install frontend dependencies
12 -RUN --mount=type=cache,target=/root/.npm \
13 - cd cmd/relay-server/frontend && \
14 - npm ci
15 -
16 -# Copy frontend source code
17 -COPY cmd/relay-server/frontend ./cmd/relay-server/frontend
18 -
19 -# Build frontend
20 -RUN cd cmd/relay-server/frontend && npm run build
21 -
22 -# Go builder stage
3 FROM --platform=$BUILDPLATFORM golang:1 AS builder
24 -
4 WORKDIR /src
5
27 -# Install make, binaryen (wasm-opt), and brotli CLI for WASM build/compression
6 RUN apt-get update && apt-get install -y --no-install-recommends \
29 - binaryen \
30 - brotli \
31 - make \
32 - && rm -rf /var/lib/apt/lists/*
7 + nodejs npm brotli make && rm -rf /var/lib/apt/lists/*
8
34 -# Set GOMODCACHE to cache Go modules in cache volume
35 -RUN go env -w GOMODCACHE=/root/.cache/go-build
36 -
37 -# Copy go.mod and go.sum
9 COPY go.mod go.sum ./
10 +RUN --mount=type=cache,target=/go/pkg/mod go mod download
11
40 -# Download dependencies
41 -RUN --mount=type=cache,target=/go/pkg/mod \
42 - go mod download
43 -
44 -# Copy the rest of the source code
12 COPY . .
13
47 -# Copy built frontend from frontend-builder stage
48 -COPY --from=frontend-builder /src/cmd/relay-server/app ./cmd/relay-server/app
49 -
50 -RUN --mount=type=cache,target=/go/pkg/mod \
51 - --mount=type=cache,target=/root/.cache/go-build \
52 - make build-wasm
14 +RUN --mount=type=cache,target=/root/.npm \
15 + make build-frontend
16
54 -ARG TARGETPLATFORM
17 ARG TARGETOS
18 ARG TARGETARCH
57 -
19 RUN --mount=type=cache,target=/go/pkg/mod \
20 --mount=type=cache,target=/root/.cache/go-build \
21 + make build-wasm && \
22 GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build-server
23
24 FROM gcr.io/distroless/static-debian12:nonroot
25
64 -# Copy server binary
26 COPY --from=builder /src/bin/relay-server /usr/bin/relay-server
27
67 -# Set default environment variables
28 ENV PORTAL_UI_URL=http://localhost:4017
29 ENV PORTAL_FRONTEND_URL=http://*.localhost:4017
30 ENV BOOTSTRAP_URIS=ws://localhost:4017/relay
31
72 -# Expose ports
73 -# 4017: relay server and portal frontend
32 EXPOSE 4017
75 -
33 ENTRYPOINT ["/usr/bin/relay-server"]
Makefile
+20 -17
@@ -33,12 +33,13 @@ build-protoc:
33 # Build WASM artifacts with wasm-opt optimization and generate manifest
34 build-wasm:
35 @echo "[wasm] building webclient WASM..."
36 - GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o cmd/relay-server/dist/portal.wasm ./cmd/webclient
36 + @mkdir -p cmd/relay-server/dist/wasm
37 + GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o cmd/relay-server/dist/wasm/portal.wasm ./cmd/webclient
38
39 @echo "[wasm] optimizing with wasm-opt..."
40 @if command -v wasm-opt >/dev/null 2>&1; then \
40 - wasm-opt -Oz --enable-bulk-memory cmd/relay-server/dist/portal.wasm -o cmd/relay-server/dist/portal.wasm.tmp && \
41 - mv cmd/relay-server/dist/portal.wasm.tmp cmd/relay-server/dist/portal.wasm; \
41 + wasm-opt -Oz --enable-bulk-memory cmd/relay-server/dist/wasm/portal.wasm -o cmd/relay-server/dist/wasm/portal.wasm.tmp && \
42 + mv cmd/relay-server/dist/wasm/portal.wasm.tmp cmd/relay-server/dist/wasm/portal.wasm; \
43 echo "[wasm] optimization complete"; \
44 else \
45 echo "[wasm] WARNING: wasm-opt not found, skipping optimization"; \
@@ -46,25 +47,25 @@ build-wasm:
47 fi
48
49 @echo "[wasm] calculating SHA256 hash..."
49 - @WASM_HASH=$$(shasum -a 256 cmd/relay-server/dist/portal.wasm | awk '{print $$1}'); \
50 + @WASM_HASH=$$(shasum -a 256 cmd/relay-server/dist/wasm/portal.wasm | awk '{print $$1}'); \
51 echo "[wasm] SHA256: $$WASM_HASH"; \
52 echo "[wasm] cleaning old hash files..."; \
52 - find cmd/relay-server/dist -name '[0-9a-f]*.wasm' ! -name "$$WASM_HASH.wasm" -type f -delete 2>/dev/null || true; \
53 - cp cmd/relay-server/dist/portal.wasm cmd/relay-server/dist/$$WASM_HASH.wasm; \
54 - rm -f cmd/relay-server/dist/portal.wasm; \
55 - echo "[wasm] content-addressed WASM: $$WASM_HASH.wasm"
53 + find cmd/relay-server/dist/wasm -name '[0-9a-f]*.wasm' ! -name "$$WASM_HASH.wasm" -type f -delete 2>/dev/null || true; \
54 + cp cmd/relay-server/dist/wasm/portal.wasm cmd/relay-server/dist/wasm/$$WASM_HASH.wasm; \
55 + rm -f cmd/relay-server/dist/wasm/portal.wasm; \
56 + echo "[wasm] content-addressed WASM: dist/wasm/$$WASM_HASH.wasm"
57
58 @echo "[wasm] copying additional resources..."
58 - @cp cmd/webclient/wasm_exec.js cmd/relay-server/dist/wasm_exec.js
59 - @cp cmd/webclient/service-worker.js cmd/relay-server/dist/service-worker.js
60 - @cp cmd/webclient/index.html cmd/relay-server/dist/portal.html
61 - @cp cmd/webclient/portal.mp4 cmd/relay-server/dist/portal.mp4
59 + @cp cmd/webclient/wasm_exec.js cmd/relay-server/dist/wasm/wasm_exec.js
60 + @cp cmd/webclient/service-worker.js cmd/relay-server/dist/wasm/service-worker.js
61 + @cp cmd/webclient/index.html cmd/relay-server/dist/wasm/portal.html
62 + @cp cmd/webclient/portal.mp4 cmd/relay-server/dist/wasm/portal.mp4
63 @echo "[wasm] build complete"
64
65 @echo "[wasm] precompressing webclient WASM with brotli..."
65 - @WASM_FILE=$$(ls cmd/relay-server/dist/[0-9a-f]*.wasm 2>/dev/null | head -n1); \
66 + @WASM_FILE=$$(ls cmd/relay-server/dist/wasm/[0-9a-f]*.wasm 2>/dev/null | head -n1); \
67 if [ -z "$$WASM_FILE" ]; then \
67 - echo "[wasm] ERROR: no content-addressed WASM found in cmd/relay-server/dist; run build-wasm first"; \
68 + echo "[wasm] ERROR: no content-addressed WASM found in cmd/relay-server/dist/wasm; run build-wasm first"; \
69 exit 1; \
70 fi; \
71 WASM_HASH=$$(basename "$$WASM_FILE" .wasm); \
@@ -72,14 +73,15 @@ build-wasm:
73 echo "[wasm] ERROR: brotli not found; install brotli to build compressed WASM"; \
74 exit 1; \
75 fi; \
75 - brotli -f "$$WASM_FILE" -o "cmd/relay-server/dist/$$WASM_HASH.wasm.br"; \
76 + brotli -f "$$WASM_FILE" -o "cmd/relay-server/dist/wasm/$$WASM_HASH.wasm.br"; \
77 rm -f "$$WASM_FILE"; \
77 - echo "[wasm] brotli: cmd/relay-server/dist/$$WASM_HASH.wasm.br"
78 + echo "[wasm] brotli: cmd/relay-server/dist/wasm/$$WASM_HASH.wasm.br"
79
80
81 # Build React frontend with Tailwind CSS 4
82 build-frontend:
83 @echo "[frontend] building React frontend..."
84 + @mkdir -p cmd/relay-server/dist/app
85 @cd cmd/relay-server/frontend && npm i && npm run build
86 @echo "[frontend] build complete"
87
@@ -95,4 +97,5 @@ build-tunnel:
97
98 clean:
99 rm -rf bin
98 - rm -rf app
100 + rm -rf cmd/relay-server/dist/app
101 + rm -rf cmd/relay-server/dist/wasm
cmd/relay-server/app/.gitkeep
cmd/relay-server/frontend/vite.config.ts
+2 -2
@@ -12,7 +12,7 @@ export default defineConfig({
12 {
13 name: "rename-index",
14 closeBundle() {
15 - const appDir = resolve(process.cwd(), "../app");
15 + const appDir = resolve(process.cwd(), "../dist/app");
16 const indexPath = resolve(appDir, "index.html");
17 const portalPath = resolve(appDir, "portal.html");
18 try {
@@ -30,7 +30,7 @@ export default defineConfig({
30 },
31 },
32 build: {
33 - outDir: "../app",
33 + outDir: "../dist/app",
34 emptyOutDir: false,
35 rollupOptions: {
36 output: {
cmd/relay-server/view.go
+11 -7
@@ -2,6 +2,7 @@ package main
2
3 import (
4 "context"
5 + "embed"
6 "encoding/json"
7 "fmt"
8 "net/http"
@@ -18,11 +19,14 @@ import (
19 "gosuda.org/portal/sdk"
20 )
21
22 +//go:embed dist/*
23 +var distFS embed.FS
24 +
25 func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
26 mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
23 -
24 - fullPath := pathpkg.Join("app", assetPath)
25 - b, err := appFS.ReadFile(fullPath)
27 + // Read from dist/app subdirectory of the embedded FS
28 + fullPath := pathpkg.Join("dist", "app", assetPath)
29 + b, err := distFS.ReadFile(fullPath)
30 if err != nil {
31 http.NotFound(w, r)
32 return
@@ -44,10 +48,10 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
48 // Create app UI mux
49 appMux := http.NewServeMux()
50
47 - // Serve embedded favicons (ico/png/svg) for portal UI
48 - serveAsset(appMux, "/favicon.ico", "/favicon.ico", "image/x-icon")
49 - serveAsset(appMux, "/favicon.png", "/favicon.png", "image/png")
50 - serveAsset(appMux, "/favicon.svg", "/favicon.svg", "image/svg+xml")
51 + // Serve favicons (ico/png/svg) from dist/app
52 + serveAsset(appMux, "/favicon.ico", "favicon.ico", "image/x-icon")
53 + serveAsset(appMux, "/favicon.png", "favicon.png", "image/png")
54 + serveAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
55
56 // Portal app assets (JS, CSS, etc.) - served from /app/
57 appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
cmd/relay-server/wasm.go renamed
+17 -24
@@ -1,7 +1,6 @@
1 package main
2
3 import (
4 - "embed"
4 "encoding/json"
5 "net/http"
6 pathpkg "path"
@@ -13,12 +12,6 @@ import (
12 "gosuda.org/portal/portal"
13 )
14
16 -//go:embed dist/*
17 -var wasmFS embed.FS
18 -
19 -//go:embed app/*
20 -var appFS embed.FS
21 -
15 // portalHost is the host for portal frontend.
16 var portalHost = "localhost"
17
@@ -44,8 +37,8 @@ var (
37
38 // initWasmCache loads pre-built WASM artifacts (precompressed) into memory on startup.
39 func initWasmCache() error {
47 - // Read all files in embedded dist directory
48 - entries, err := wasmFS.ReadDir("dist")
40 + // Read all files in embedded dist/wasm directory
41 + entries, err := distFS.ReadDir("dist/wasm")
42 if err != nil {
43 return err
44 }
@@ -60,7 +53,7 @@ func initWasmCache() error {
53 if strings.HasSuffix(name, ".wasm.br") && len(name) == 72 { // 64 + len(".wasm.br")
54 hash := strings.TrimSuffix(name, ".wasm.br")
55 if isHexString(hash) && len(hash) == 64 {
63 - fullPath := pathpkg.Join("dist", name)
56 + fullPath := pathpkg.Join("dist", "wasm", name)
57 // Cache under the URL path (<hash>.wasm) while reading the
58 // brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
59 cacheKey := hash + ".wasm"
@@ -86,7 +79,7 @@ func cacheWasmFile(name, fullPath string) error {
79
80 // Load precompressed variant (brotli) from embed.FS (<hash>.wasm.br)
81 var brData []byte
89 - data, err := wasmFS.ReadFile(fullPath)
82 + data, err := distFS.ReadFile(fullPath)
83 if err != nil {
84 log.Warn().Err(err).Str("file", fullPath).Msg("failed to read brotli-compressed WASM")
85 } else {
@@ -168,8 +161,8 @@ func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal
161 setCORSHeaders(w)
162
163 // Read portal.html from embedded FS
171 - fullPath := pathpkg.Join("app", "portal.html")
172 - htmlContent, err := appFS.ReadFile(fullPath)
164 + fullPath := pathpkg.Join("dist", "app", "portal.html")
165 + htmlContent, err := distFS.ReadFile(fullPath)
166 if err != nil {
167 log.Error().Err(err).Msg("Failed to read portal.html")
168 http.NotFound(w, r)
@@ -241,8 +234,8 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string
234 if !ok {
235 log.Debug().Str("path", filePath).Msg("WASM file not in cache")
236 // Fallback: try to serve uncompressed WASM from embedded FS
244 - fullPath := pathpkg.Join("dist", filePath)
245 - data, err := wasmFS.ReadFile(fullPath)
237 + fullPath := pathpkg.Join("dist", "wasm", filePath)
238 + data, err := distFS.ReadFile(fullPath)
239 if err != nil {
240 log.Debug().Err(err).Str("path", fullPath).Msg("WASM file not found in embedded FS")
241 http.NotFound(w, r)
@@ -310,8 +303,8 @@ func serveAppStatic(w http.ResponseWriter, r *http.Request, path string, serv *p
303 }
304
305 // Try to read from embedded FS
313 - fullPath := pathpkg.Join("app", path)
314 - data, err := appFS.ReadFile(fullPath)
306 + fullPath := pathpkg.Join("dist", "app", path)
307 + data, err := distFS.ReadFile(fullPath)
308 if err != nil {
309 // File not found - fallback to portal.html with SSR for SPA routing
310 log.Debug().Err(err).Str("path", path).Msg("app static file not found, falling back to SSR")
@@ -381,8 +374,8 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
374 func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, contentType string) {
375 setCORSHeaders(w)
376
384 - fullPath := pathpkg.Join("dist", path)
385 - data, err := wasmFS.ReadFile(fullPath)
377 + fullPath := pathpkg.Join("dist", "wasm", path)
378 + data, err := distFS.ReadFile(fullPath)
379 if err != nil {
380 log.Debug().Err(err).Str("path", path).Msg("static file not found")
381 http.NotFound(w, r)
@@ -414,8 +407,8 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, conten
407 func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path string, contentType string) {
408 setCORSHeaders(w)
409
417 - fullPath := pathpkg.Join("dist", path)
418 - data, err := wasmFS.ReadFile(fullPath)
410 + fullPath := pathpkg.Join("dist", "wasm", path)
411 + data, err := distFS.ReadFile(fullPath)
412 if err != nil {
413 // File not found - fallback to portal.html for SPA routing
414 log.Debug().Err(err).Str("path", path).Msg("static file not found, serving portal.html")
@@ -524,7 +517,7 @@ func serveDynamicManifest(w http.ResponseWriter) {
517
518 // Fallback: scan embedded WASM directory if cache is empty
519 if wasmHash == "" {
527 - entries, err := wasmFS.ReadDir("dist")
520 + entries, err := distFS.ReadDir("dist/wasm")
521 if err == nil {
522 for _, entry := range entries {
523 if entry.IsDir() {
@@ -580,8 +573,8 @@ func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
573 setCORSHeaders(w)
574
575 // Read the service-worker.js template
583 - fullPath := pathpkg.Join("dist", "service-worker.js")
584 - content, err := wasmFS.ReadFile(fullPath)
576 + fullPath := pathpkg.Join("dist", "wasm", "service-worker.js")
577 + content, err := distFS.ReadFile(fullPath)
578 if err != nil {
579 log.Error().Err(err).Msg("Failed to read service-worker.js")
580 http.NotFound(w, r)