web: recover stale cached clients after deploy instead of freezing
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
The SPA references content-hashed bundles under /_app/immutable/ embedded in the binary. After a deploy the binary ships new hashes and drops the old ones, so a browser reusing a cached HTML page (or a stale /_app bundle) requests hashes that 404: the page renders but the app never starts, leaving it visible but frozen. - routes.go: serve the page HTML (and the rewritten manifest.json) with Cache-Control no-cache so clients always revalidate and load a page that points at bundles which actually exist; /_app/* stays immutable. - routes.go: when a missing /_app/immutable/*.js is requested (a stale cached client), return a self-healing reload module (200) instead of a dead 404 so already-stuck clients recover on their own. The reload module is issued synchronously and throws at the end of evaluation so the failing import() rejects: SvelteKit's bootstrap never reaches kit.start(...), avoiding a "kit.start is not a function" TypeError. We do not touch the Cache Storage API: the service worker manages its own cache lifecycle and async caches.* work would lose the race against kit.start.
This commit is contained in:
parent
16bc06359c
commit
96935f11c5
1 changed files with 96 additions and 1 deletions
|
|
@ -46,6 +46,65 @@ var (
|
|||
MsgHeaderText = ""
|
||||
)
|
||||
|
||||
// staleReloadJS is served (with a 200) in place of a 404 when a client requests
|
||||
// a content-hashed bundle under /_app/immutable/ that no longer exists. That can
|
||||
// only happen to a browser running a *stale, cached* HTML page after a deploy:
|
||||
// the embedded binary ships new bundle hashes and drops the old ones, so the
|
||||
// page's bootstrapping import() would normally 404 and the SPA would render but
|
||||
// never finish starting (visible but frozen).
|
||||
//
|
||||
// Because the browser evaluates this as the very module it was trying to import,
|
||||
// returning a tiny self-healing script here lets already-stuck clients recover on
|
||||
// their own: it forces a one-shot cache-busting reload so the browser fetches the
|
||||
// current HTML (which points at bundles that actually exist).
|
||||
//
|
||||
// Reload-loop guard: the primary guard is storage-independent — if the URL
|
||||
// already carries our _fresh marker, we already redirected once and the bundle
|
||||
// is *still* missing, so the deploy is genuinely broken; we stop reloading and
|
||||
// let the import reject rather than spin. The sessionStorage check is only a
|
||||
// best-effort secondary guard across separate navigations, and its failure (e.g.
|
||||
// Safari private mode throwing on setItem) can no longer cause an ungated loop.
|
||||
//
|
||||
// Two things make this robust against "kit.start is not a function":
|
||||
// 1. The reload is issued SYNCHRONOUSLY during evaluation, so navigation is
|
||||
// committed before SvelteKit's bootstrap `.then(([kit]) => kit.start(...))`
|
||||
// microtask can run against this (non-)module.
|
||||
// 2. We THROW at the end of evaluation, so the failing `import()` rejects
|
||||
// instead of resolving to this module. Promise.all rejects, the bootstrap
|
||||
// `.then` is skipped, and kit.start is never reached. On a guarded second
|
||||
// load (when no reload is issued) this surfaces as a clean import rejection
|
||||
// rather than a confusing TypeError.
|
||||
//
|
||||
// Note: happyDomain ships a service worker, but we deliberately do NOT touch the
|
||||
// Cache Storage API here. The service worker already manages its own cache
|
||||
// lifecycle (it drops old-version caches on activate), the synchronous reload
|
||||
// fetches fresh HTML through the worker's network-first path anyway, and doing
|
||||
// async `caches.*` work would only push the reload into a microtask chain that
|
||||
// loses the race against kit.start.
|
||||
const staleReloadJS = `(function () {
|
||||
var KEY = '__hd_stale_reload__';
|
||||
var now = Date.now();
|
||||
// Storage-independent guard: if we already redirected with _fresh and the
|
||||
// bundle is still missing, stop here (no reload loop, even without storage).
|
||||
try {
|
||||
if (new URL(window.location.href).searchParams.has('_fresh')) return;
|
||||
} catch (e) {}
|
||||
// Best-effort secondary guard across separate navigations; failure is ignored.
|
||||
try {
|
||||
if (now - parseInt(sessionStorage.getItem(KEY) || '0', 10) < 10000) return;
|
||||
sessionStorage.setItem(KEY, String(now));
|
||||
} catch (e) {}
|
||||
try {
|
||||
var u = new URL(window.location.href);
|
||||
u.searchParams.set('_fresh', String(now));
|
||||
window.location.replace(u.toString());
|
||||
} catch (e) {
|
||||
try { window.location.reload(); } catch (e2) {}
|
||||
}
|
||||
})();
|
||||
throw new Error('happydomain: stale bundle, reloading');
|
||||
`
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before </head>")
|
||||
flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before </body>")
|
||||
|
|
@ -163,6 +222,14 @@ func NoRoute(cfg *happydns.Options, router *gin.Engine) {
|
|||
})
|
||||
}
|
||||
|
||||
// setNoCache marks a response as revalidate-before-reuse. It is the inverse of
|
||||
// the immutable middleware and is used for the documents (index.html, manifest,
|
||||
// and the stale-bundle fallback) that must never pin a browser to dropped
|
||||
// content-hashed bundles after a deploy. See staleReloadJS.
|
||||
func setNoCache(c *gin.Context) {
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
||||
if cfg.DevProxy != "" {
|
||||
// Parse once at creation time, not per request
|
||||
|
|
@ -280,6 +347,10 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
|||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Revalidate the HTML so clients always load a page pointing at
|
||||
// content-hashed bundles that still exist after a deploy, rather
|
||||
// than a cached page referencing dropped hashes. See staleReloadJS.
|
||||
setNoCache(c)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
|
||||
}
|
||||
} else if forced_url == "/manifest.json" {
|
||||
|
|
@ -297,6 +368,7 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
|||
}
|
||||
v2 := strings.Replace(strings.Replace(string(v), "\"id\": \"/\"", "\"id\": \""+cfg.BasePath+"\"/", 1), "\"start_url\": \"/\"", "\"start_url\": \""+cfg.BasePath+"/\"", 1)
|
||||
|
||||
setNoCache(c)
|
||||
c.Data(http.StatusOK, "application/manifest+json", []byte(v2))
|
||||
}
|
||||
} else if forced_url != "" {
|
||||
|
|
@ -307,7 +379,30 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
|||
} else {
|
||||
// Serve requested file
|
||||
return func(c *gin.Context) {
|
||||
c.FileFromFS(strings.TrimPrefix(c.Request.URL.Path, cfg.BasePath), Assets)
|
||||
reqPath := strings.TrimPrefix(c.Request.URL.Path, cfg.BasePath)
|
||||
|
||||
// A missing content-hashed bundle means a stale cached client: hand
|
||||
// it a self-healing reload module (200) instead of a dead 404 so an
|
||||
// already-stuck client recovers on its own.
|
||||
if strings.HasPrefix(reqPath, "/_app/immutable/") && strings.HasSuffix(reqPath, ".js") {
|
||||
f, err := Assets.Open(reqPath)
|
||||
if err != nil {
|
||||
setNoCache(c)
|
||||
c.Data(http.StatusOK, "text/javascript; charset=utf-8", []byte(staleReloadJS))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Serve straight from the handle we just opened rather than
|
||||
// letting FileFromFS reopen and re-stat the same file: this is
|
||||
// the hot path (every JS chunk of every page load).
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
http.ServeContent(c.Writer, c.Request, reqPath, fi.ModTime(), f)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.FileFromFS(reqPath, Assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue