fix: forward Set-Cookie headers through Service Worker tunnel

Hee Sung Son committed Jan 21, 2026 at 23:31 UTC f9db5729b2e280d55a47605d9877384dcc88322b
5 files changed +144 -21
cmd/demo-app/main.go
+47 -8
@@ -24,13 +24,14 @@ import (
24 var staticFiles embed.FS
25
26 var (
27 - flagServerURL string
28 - flagPort int
29 - flagName string
30 - flagDesc string
31 - flagTags string
32 - flagOwner string
33 - flagHide bool
27 + flagServerURL string
28 + flagPort int
29 + flagName string
30 + flagDesc string
31 + flagTags string
32 + flagOwner string
33 + flagThumbnail string
34 + flagHide bool
35 )
36
37 func main() {
@@ -38,8 +39,9 @@ func main() {
39 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
40 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
41 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
41 - flag.StringVar(&flagTags, "tags", "demo,connectivity", "comma-separated lease tags")
42 + flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,moning", "comma-separated lease tags")
43 flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
44 + flag.StringVar(&flagThumbnail, "thumbnail", "https://naverpa-phinf.pstatic.net/MjAyNjAxMTRfMjEy/MDAxNzY4MzcxNzc1NTYz.nE942d-5NNcGgRlFBukeLEL9s8fESvMaKL1Uv8eiyNAg.k6gjFLfHcvL6pzWqrQhEPf-JgGCYyEluGv-2fZA2b4Qg.PNG/%EB%84%A4%EC%9D%B4%EB%B2%84_GFA-PC%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-342_228_17683717755352279343803638606717.png", "lease thumbnail URL")
45 flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
46
47 flag.Parse()
@@ -95,6 +97,7 @@ func runDemo() error {
97 sdk.WithDescription(flagDesc),
98 sdk.WithTags(strings.Split(flagTags, ",")),
99 sdk.WithOwner(flagOwner),
100 + sdk.WithThumbnail(flagThumbnail),
101 sdk.WithHide(flagHide),
102 )
103 if err != nil {
@@ -127,9 +130,45 @@ func runDemo() error {
130 // WebSocket echo endpoint for bidirectional test
131 mux.HandleFunc("/ws", handleWS)
132
133 + // Test endpoint for multiple Set-Cookie headers
134 + mux.HandleFunc("/api/test-cookies", func(w http.ResponseWriter, r *http.Request) {
135 + http.SetCookie(w, &http.Cookie{
136 + Name: "session_id",
137 + Value: "abc123",
138 + Path: "/",
139 + HttpOnly: true,
140 + MaxAge: 3600,
141 + })
142 + http.SetCookie(w, &http.Cookie{
143 + Name: "csrf_token",
144 + Value: "xyz789",
145 + Path: "/",
146 + MaxAge: 3600,
147 + })
148 + http.SetCookie(w, &http.Cookie{
149 + Name: "user_pref",
150 + Value: "dark_mode",
151 + Path: "/",
152 + MaxAge: 86400,
153 + })
154 + w.Header().Set("Content-Type", "application/json")
155 + json.NewEncoder(w).Encode(map[string]any{
156 + "message": "3 cookies set: session_id, csrf_token, user_pref",
157 + })
158 + })
159 +
160 // 5) Serve HTTP over relay listener
161 log.Info().Msgf("[demo] serving HTTP over relay; lease=%s id=%s", flagName, cred.ID())
162
163 + // Also serve on local port for direct testing
164 + go func() {
165 + localAddr := fmt.Sprintf(":%d", flagPort)
166 + log.Info().Msgf("[demo] also serving on local port %s for direct testing", localAddr)
167 + if err := http.ListenAndServe(localAddr, mux); err != nil {
168 + log.Error().Err(err).Msg("local http serve error")
169 + }
170 + }()
171 +
172 srvErr := make(chan error, 1)
173 go func() {
174 srvErr <- http.Serve(listener, mux)
cmd/demo-app/static/index.html
+23
@@ -16,6 +16,7 @@
16
17 <div class="toolbar">
18 <button id="httpPingBtn">HTTP Ping</button>
19 + <button id="testCookiesBtn">Test Cookies</button>
20 <button id="wsConnectBtn">WS Connect</button>
21 <button id="wsSendBtn" disabled>WS Send "hello"</button>
22 </div>
@@ -55,6 +56,28 @@
56 }
57 });
58
59 + document.getElementById('testCookiesBtn').addEventListener('click', async () => {
60 + statusEl.textContent = 'Cookies: Testing...';
61 + try {
62 + const res = await fetch('/api/test-cookies');
63 + const json = await res.json();
64 + statusEl.textContent = 'Cookies: OK';
65 + log(`HTTP /api/test-cookies -> ${JSON.stringify(json)}`);
66 +
67 + // Log response headers
68 + log(`Response headers:`);
69 + for (const [key, value] of res.headers.entries()) {
70 + log(` ${key}: ${value}`);
71 + }
72 +
73 + // Log current cookies
74 + log(`Current document.cookie: ${document.cookie || '(empty)'}`);
75 + } catch (err) {
76 + statusEl.textContent = 'Cookies: Error';
77 + log(`HTTP error: ${err}`);
78 + }
79 + });
80 +
81 wsConnectBtn.addEventListener('click', () => {
82 if (ws && ws.readyState === WebSocket.OPEN) {
83 ws.close();
cmd/webclient/httpjs/http_js.go
+24 -5
@@ -405,11 +405,19 @@ func JSRequestToHTTPRequest(jsReq js.Value) (*http.Request, error) {
405
406 // HTTPResponseToJSResponse converts an http.Response to a JavaScript Response object with streaming support
407 func HTTPResponseToJSResponse(httpResp *http.Response) js.Value {
408 - // Create JS headers object
409 - jsHeaders := _Object.New()
408 + // Create JS Headers object (supports multiple values per key, e.g., Set-Cookie)
409 + jsHeaders := _Headers.New()
410 +
411 + // Extract Set-Cookie separately (browsers filter them from Response headers)
412 + // http.Header uses canonical form "Set-Cookie", so direct lookup is safe
413 + setCookies := httpResp.Header["Set-Cookie"]
414 +
415 for key, values := range httpResp.Header {
411 - if len(values) > 0 {
412 - jsHeaders.Set(key, values[0])
416 + if key == "Set-Cookie" {
417 + continue // handled separately via _setCookies property
418 + }
419 + for _, value := range values {
420 + jsHeaders.Call("append", key, value)
421 }
422 }
423
@@ -428,8 +436,19 @@ func HTTPResponseToJSResponse(httpResp *http.Response) js.Value {
436 jsOptions.Set("statusText", httpResp.Status)
437 jsOptions.Set("headers", jsHeaders)
438
431 - // Create and return JS Response
439 + // Create JS Response
440 jsResp := _Response.New(jsBody, jsOptions)
441 +
442 + // Attach Set-Cookie values as a custom property (browsers filter Set-Cookie from headers)
443 + // Service Worker will read this property and forward cookies via postMessage
444 + if len(setCookies) > 0 {
445 + jsArray := _Array.New(len(setCookies))
446 + for i, cookie := range setCookies {
447 + jsArray.SetIndex(i, cookie)
448 + }
449 + jsResp.Set("_setCookies", jsArray)
450 + }
451 +
452 return jsResp
453 }
454
cmd/webclient/polyfill.js
+38 -8
@@ -18,6 +18,35 @@
18 }
19 }
20
21 + // Global Service Worker message handler registry
22 + // Key: clientId, Value: handler function
23 + const swMessageHandlers = new Map();
24 +
25 + // Single global Service Worker message listener
26 + if ("serviceWorker" in navigator) {
27 + navigator.serviceWorker.addEventListener("message", (event) => {
28 + const data = event.data;
29 + if (!data) return;
30 +
31 + // Handle SET_COOKIES globally (browsers ignore Set-Cookie in SW responses)
32 + if (data.type === "SET_COOKIES") {
33 + const cookies = data.cookies;
34 + if (Array.isArray(cookies)) {
35 + for (let i = 0; i < cookies.length; i++) {
36 + document.cookie = cookies[i];
37 + }
38 + }
39 + return;
40 + }
41 +
42 + // Route to per-client WebSocket handler
43 + if (data.clientId) {
44 + const handler = swMessageHandlers.get(data.clientId);
45 + if (handler) handler(data);
46 + }
47 + });
48 + }
49 +
50 // Generate unique client ID
51 function generateClientId() {
52 return `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -160,14 +189,8 @@
189 }
190
191 _setupMessageListener() {
163 - navigator.serviceWorker.addEventListener("message", (event) => {
164 - const data = event.data;
165 -
166 - // Only handle messages for this client
167 - if (data.clientId !== this._clientId) {
168 - return;
169 - }
170 -
192 + // Register handler in global registry (single SW listener handles routing)
193 + swMessageHandlers.set(this._clientId, (data) => {
194 switch (data.type) {
195 case "SDK_CONNECT_SUCCESS":
196 this._handleConnectSuccess(data);
@@ -188,6 +211,10 @@
211 });
212 }
213
214 + _cleanupMessageListener() {
215 + swMessageHandlers.delete(this._clientId);
216 + }
217 +
218 // Build HTTP upgrade request bytes (used for pipelining)
219 _buildUpgradeRequest() {
220 // Parse URL to get path with query parameters
@@ -619,6 +646,9 @@
646 });
647 }
648
649 + // Cleanup message listener
650 + this._cleanupMessageListener();
651 +
652 // Handle close locally
653 this._handleDataClose({ code, reason });
654 }
cmd/webclient/service-worker.js
+12
@@ -790,6 +790,18 @@ self.addEventListener("fetch", (e) => {
790
791 // Process request
792 const resp = await __go_jshttp(e.request);
793 +
794 + // Forward Set-Cookie values to client via postMessage
795 + // (browsers filter Set-Cookie from Response headers, so Go WASM attaches them as _setCookies)
796 + const setCookies = resp._setCookies;
797 + if (setCookies && setCookies.length > 0) {
798 + const msg = { type: "SET_COOKIES", cookies: setCookies };
799 + const clientId = e.clientId || e.resultingClientId;
800 + if (clientId) {
801 + self.clients.get(clientId).then(c => c && c.postMessage(msg));
802 + }
803 + }
804 +
805 return resp;
806 } catch (error) {
807 console.error("[SW] Request handling failed:", error);