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