feat(relay-server): add CORS support to static file endpoints

- Removed BOOTSTRAPS build arg from Dockerfile and simplified build-wasm command - Updated BOOTSTRAP_URIS env var to use only local relay for development - Added setCORSHeaders function and applied it to all static file handlers (/static/, /frontend/, root, WASM, admin, manifest, service worker) to enable cross-origin requests for better portal UI integration

lemon-mint committed Nov 7, 2025 at 01:50 UTC ad3f9377af2fdee8d2ccbefc04665d0d35e2a99f
3 files changed +45 -3
Dockerfile
+2 -3
@@ -14,8 +14,7 @@ RUN go mod download
14 COPY . .
15
16 # Build WASM and server
17 -ARG BOOTSTRAPS=""
18 -RUN make build-wasm BOOTSTRAPS="$BOOTSTRAPS"
17 +RUN make build-wasm
18
19 # Build server
20 RUN make build-server
@@ -32,7 +31,7 @@ COPY --from=builder /src/dist /app/dist
31 ENV STATIC_DIR=/app/dist
32 ENV PORTAL_UI_URL=http://localhost:4017
33 ENV POSTAL_FRONTEND_URL=http://*.localhost:4017
35 -ENV BOOTSTRAP_URIS=ws://localhost:4017/relay,wss://some.app:21762/relay
34 +ENV BOOTSTRAP_URIS=ws://localhost:4017/relay
35
36 # Expose ports
37 # 4017: relay server and portal frontend
cmd/relay-server/frontend.go
+33
@@ -141,6 +141,13 @@ func cacheWasmFile(name, fullPath string) error {
141 return nil
142 }
143
144 +// setCORSHeaders sets CORS headers for static file serving
145 +func setCORSHeaders(w http.ResponseWriter) {
146 + w.Header().Set("Access-Control-Allow-Origin", "*")
147 + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
148 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
149 +}
150 +
151 // createPortalMux creates a new HTTP mux for portal frontend
152 func createPortalMux() *http.ServeMux {
153 // Initialize WASM cache on startup
@@ -152,12 +159,22 @@ func createPortalMux() *http.ServeMux {
159
160 // Static file handler for /static/
161 mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
162 + setCORSHeaders(w)
163 + if r.Method == http.MethodOptions {
164 + w.WriteHeader(http.StatusOK)
165 + return
166 + }
167 path := strings.TrimPrefix(r.URL.Path, "/static/")
168 servePortalStaticFile(w, r, path)
169 })
170
171 // Static file handler for /frontend/ (for unified caching)
172 mux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
173 + setCORSHeaders(w)
174 + if r.Method == http.MethodOptions {
175 + w.WriteHeader(http.StatusOK)
176 + return
177 + }
178 path := strings.TrimPrefix(r.URL.Path, "/frontend/")
179
180 // Special handling for manifest.json - generate dynamically
@@ -171,6 +188,11 @@ func createPortalMux() *http.ServeMux {
188
189 // Root handler for portal frontend
190 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
191 + setCORSHeaders(w)
192 + if r.Method == http.MethodOptions {
193 + w.WriteHeader(http.StatusOK)
194 + return
195 + }
196 if r.URL.Path == "/" {
197 serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
198 return
@@ -212,6 +234,7 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, path string) {
234 }
235
236 // Set immutable cache headers for content-addressed files
237 + setCORSHeaders(w)
238 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
239 w.Header().Set("Content-Type", "application/wasm")
240
@@ -266,6 +289,8 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, path string) {
289 }
290
291 // Try to read from embedded FS
292 + setCORSHeaders(w)
293 +
294 fullPath := filepath.Join("static", path)
295 data, err := assetsFS.ReadFile(fullPath)
296 if err != nil {
@@ -334,6 +359,8 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
359
360 // serveStaticFile reads and serves a file from the static directory
361 func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, contentType string) {
362 + setCORSHeaders(w)
363 +
364 fullPath := filepath.Join(staticDir, path)
365
366 file, err := os.Open(fullPath)
@@ -374,6 +401,8 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, path string, conten
401 // serveStaticFileWithFallback reads and serves a file from the static directory
402 // If the file is not found, it falls back to portal.html for SPA routing
403 func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path string, contentType string) {
404 + setCORSHeaders(w)
405 +
406 fullPath := filepath.Join(staticDir, path)
407
408 file, err := os.Open(fullPath)
@@ -477,6 +506,8 @@ func isHexString(s string) bool {
506
507 // serveDynamicManifest generates and serves manifest.json dynamically
508 func serveDynamicManifest(w http.ResponseWriter, r *http.Request) {
509 + setCORSHeaders(w)
510 +
511 // Find the content-addressed WASM file
512 wasmCacheMu.RLock()
513 var wasmHash string
@@ -543,6 +574,8 @@ func serveDynamicManifest(w http.ResponseWriter, r *http.Request) {
574
575 // serveDynamicServiceWorker serves service-worker.js with injected manifest and config
576 func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
577 + setCORSHeaders(w)
578 +
579 // Read the service-worker.js template
580 fullPath := filepath.Join(staticDir, "service-worker.js")
581 content, err := os.ReadFile(fullPath)
cmd/relay-server/view.go
+10
@@ -53,12 +53,22 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
53
54 // Static assets for admin UI (embedded files)
55 adminMux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
56 + setCORSHeaders(w)
57 + if r.Method == http.MethodOptions {
58 + w.WriteHeader(http.StatusOK)
59 + return
60 + }
61 path := strings.TrimPrefix(r.URL.Path, "/static/")
62 serveAdminStatic(w, r, path)
63 })
64
65 // Portal frontend files (for unified caching)
66 adminMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
67 + setCORSHeaders(w)
68 + if r.Method == http.MethodOptions {
69 + w.WriteHeader(http.StatusOK)
70 + return
71 + }
72 path := strings.TrimPrefix(r.URL.Path, "/frontend/")
73
74 // Special handling for manifest.json - generate dynamically