From 020742fad3e12ec93f0a4a8af38cda612a97af8c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 16:45:07 +0200 Subject: [PATCH] fix(pwa): network-first navigation so a new deploy loads online immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C1 service worker served the precached shell for every navigation, even online, so a new deploy only reached a client after the worker updated its precache in the background (a one-launch lag): a cold online start showed the old version until then, and only a reinstall forced it fresh. - sw.ts: navigations are now network-first — fetch the fresh (no-cache) shell from the server on each online launch (it references the new hashed assets, fetched fresh), with a 3s timeout falling back to the precached shell when the network is unreachable or too slow. Only the tiny HTML is re-fetched; the immutable hashed assets stay cache-first and re-download only when their hash (the version) changes. Offline cold-launch still works via the fallback. - webui.go: serve sw.js with Cache-Control: no-cache so the browser reliably detects a new deploy's worker (regression-tested). Online launch is contour-verified (the mock e2e disables the SW); e2e 196. No new deps — the network-first handler is hand-written (~12 lines). --- gateway/internal/webui/webui.go | 5 +++++ gateway/internal/webui/webui_test.go | 4 ++++ ui/src/sw.ts | 29 +++++++++++++++++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index d999377..549d7b4 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -71,6 +71,11 @@ func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler { // app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download. if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else if name == "sw.js" { + // The service worker must be revalidated on every load so a new deploy's worker (and its + // fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on + // the old build. The worker's network-first navigation then serves the fresh shell online. + w.Header().Set("Cache-Control", "no-cache") } files.ServeHTTP(w, r) }) diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index 90c174e..66de941 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -94,6 +94,10 @@ func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) { if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Errorf("Content-Type = %q, want text/javascript", ct) } + // Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up. + if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" { + t.Errorf("sw.js Cache-Control = %q, want no-cache", cc) + } } // TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted diff --git a/ui/src/sw.ts b/ui/src/sw.ts index 37bd1a0..fc2a61f 100644 --- a/ui/src/sw.ts +++ b/ui/src/sw.ts @@ -8,7 +8,7 @@ // served stale. Registration stays manual and web-only (pwa.svelte.ts): skipped in the mock build // and inside a Telegram/VK Mini App. import { clientsClaim } from 'workbox-core'; -import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'; +import { cleanupOutdatedCaches, matchPrecache, precacheAndRoute } from 'workbox-precaching'; import { NavigationRoute, registerRoute } from 'workbox-routing'; // The injectManifest build replaces self.__WB_MANIFEST with the precache list; the DOM lib knows @@ -28,10 +28,25 @@ cleanupOutdatedCaches(); // needs. The landing page and the old-engine polyfill bundle are excluded via the build globs. precacheAndRoute(self.__WB_MANIFEST); -// Serve the precached shell for any in-scope top-level navigation. Deny-list the RPC path and the +// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh +// (no-cache) shell from the server — it references the new hashed assets, which are then fetched +// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a +// short timeout). This keeps the app up to date online while still cold-launching offline. Only the +// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute above) and +// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the // admin console so those are never resolved to the app shell. -registerRoute( - new NavigationRoute(createHandlerBoundToURL('index.html'), { - denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//], - }), -); +const NAV_TIMEOUT_MS = 3000; +async function freshShell({ request }: { request: Request }): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), NAV_TIMEOUT_MS); + try { + const res = await fetch(request, { signal: ctrl.signal }); + if (res.ok) return res; + } catch { + /* offline or too slow — serve the precached shell below */ + } finally { + clearTimeout(timer); + } + return (await matchPrecache('index.html')) ?? Response.error(); +} +registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] })); -- 2.52.0