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().