feat: add legacy service worker cleanup and compatibility endpoints for legacy webclient
gosunuts committed
Feb 25, 2026 at 11:49 UTC
f3621bf0aba69b1807df7859ba96c1ad626d7e04
2 files changed
+129
cmd/relay-server/frontend.go
+118
@@ -82,6 +82,9 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
82
// Inject OG metadata (defaults for main app)
83
injectedHTML = f.injectOGMetadata(injectedHTML, "", "", "")
84
85
+ // Force one-time cleanup of legacy service workers/caches before app boot.
86
+ injectedHTML = strings.Replace(injectedHTML, "</head>", legacyCleanupBootstrapJS+"\n</head>", 1)
87
+
88
// Set headers
89
w.Header().Set("Content-Type", "text/html; charset=utf-8")
90
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
@@ -93,6 +96,121 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
96
log.Debug().Msg("Served portal.html with SSR data")
97
}
98
99
+const legacyServiceWorkerCleanupJS = `/* Portal legacy SW cleanup worker */
100
+self.addEventListener("install", (event) => {
101
+ event.waitUntil(self.skipWaiting());
102
+});
103
+
104
+self.addEventListener("activate", (event) => {
105
+ event.waitUntil((async () => {
106
+ try {
107
+ const keys = await caches.keys();
108
+ await Promise.all(keys.map((k) => caches.delete(k)));
109
+ } catch (_) {}
110
+
111
+ await self.clients.claim();
112
+ await self.registration.unregister();
113
+
114
+ const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
115
+ for (const client of clients) {
116
+ client.navigate(client.url);
117
+ }
118
+ })());
119
+});
120
+
121
+self.addEventListener("fetch", (event) => {
122
+ event.respondWith(fetch(event.request));
123
+});
124
+`
125
+
126
+const legacyCleanupBootstrapJS = `<script>
127
+(function () {
128
+ if (!("serviceWorker" in navigator)) {
129
+ return;
130
+ }
131
+
132
+ var marker = "portal-sw-cleanup-v2";
133
+ try {
134
+ if (sessionStorage.getItem(marker) === "1") {
135
+ return;
136
+ }
137
+ sessionStorage.setItem(marker, "1");
138
+ } catch (_) {}
139
+
140
+ var unregister = navigator.serviceWorker.getRegistrations().then(function (regs) {
141
+ return Promise.all(
142
+ regs.map(function (reg) {
143
+ return reg.unregister();
144
+ })
145
+ );
146
+ });
147
+
148
+ var clearCaches = typeof caches === "undefined"
149
+ ? Promise.resolve()
150
+ : caches.keys().then(function (keys) {
151
+ return Promise.all(
152
+ keys.map(function (k) {
153
+ return caches.delete(k);
154
+ })
155
+ );
156
+ });
157
+
158
+ Promise.all([unregister, clearCaches]).finally(function () {
159
+ location.reload();
160
+ });
161
+})();
162
+</script>`
163
+
164
+// ServeLegacyServiceWorkerCleanup serves a compatibility service worker
165
+// that unregisters itself and clears caches from legacy webclient deployments.
166
+func (f *Frontend) ServeLegacyServiceWorkerCleanup(w http.ResponseWriter, r *http.Request) {
167
+ setCORSHeaders(w)
168
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
169
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
170
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
171
+ return
172
+ }
173
+
174
+ w.Header().Set("Content-Type", "application/javascript")
175
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
176
+ w.Header().Set("Pragma", "no-cache")
177
+ w.Header().Set("Expires", "0")
178
+ w.WriteHeader(http.StatusOK)
179
+ if r.Method == http.MethodGet {
180
+ _, _ = w.Write([]byte(legacyServiceWorkerCleanupJS))
181
+ }
182
+}
183
+
184
+// ServeLegacyFrontendCompat handles removed /frontend/* endpoints from legacy webclient.
185
+func (f *Frontend) ServeLegacyFrontendCompat(w http.ResponseWriter, r *http.Request) {
186
+ setCORSHeaders(w)
187
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
188
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
189
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
190
+ return
191
+ }
192
+
193
+ p := strings.TrimPrefix(r.URL.Path, "/frontend/")
194
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
195
+ w.Header().Set("Pragma", "no-cache")
196
+ w.Header().Set("Expires", "0")
197
+
198
+ if p == "manifest.json" {
199
+ w.Header().Set("Content-Type", "application/json")
200
+ w.WriteHeader(http.StatusGone)
201
+ if r.Method == http.MethodGet {
202
+ _, _ = w.Write([]byte(`{"success":false,"message":"legacy webclient removed; refresh required"}`))
203
+ }
204
+ return
205
+ }
206
+
207
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
208
+ w.WriteHeader(http.StatusGone)
209
+ if r.Method == http.MethodGet {
210
+ _, _ = w.Write([]byte("legacy webclient assets removed; refresh required"))
211
+ }
212
+}
213
+
214
// injectOGMetadata replaces OG placeholders with actual values.
215
func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
216
if title == "" {
cmd/relay-server/serve.go
+11
@@ -89,6 +89,17 @@ func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *
89
// Create the main handler
90
appDomain := defaultAppPattern(flagPortalURL)
91
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
+ // Compatibility endpoints for legacy webclient deployments.
93
+ // Handle before host-based routing so stale service workers can recover.
94
+ if r.URL.Path == "/service-worker.js" {
95
+ frontend.ServeLegacyServiceWorkerCleanup(w, r)
96
+ return
97
+ }
98
+ if strings.HasPrefix(r.URL.Path, "/frontend/") {
99
+ frontend.ServeLegacyFrontendCompat(w, r)
100
+ return
101
+ }
102
+
103
// Handle subdomain requests
104
if isSubdomain(appDomain, r.Host) {
105
log.Debug().