WIP: Client offline mode through Service Workers#425
Conversation
|
Local review notes — flagging this as not blocking. The scope here is genuinely bigger than what 1. Comparison with
|
| online-check page | SW in this PR | |
|---|---|---|
| Goal | Don't boot SPA until network is verified | Boot SPA from cache when network is gone |
| Code surface | One self-contained HTML, all inline (public/client/online-check/index.html) |
60-line generator + 91-line template + nginx rules + Vite/build wiring + a registration hook |
| Failure modes | Mostly one (cache served, no network → keeps polling, shown as "Offline") | Several: install atomicity, cache eviction, sw.js update timing, asset-hash drift, navigation-cache miss |
| Build coupling | None | Tightly coupled to the Vite manifest crawl |
| First boot | Page works whether or not network is present | Does not help first-ever boot (see §3) |
Stability: the existing page has a much smaller blast radius. Everything is inline; no install step that can half-finish, no version skew, no race with browser SW lifecycle. The PR introduces several non-trivial offline-boot failure modes that aren't possible today — worth it for the broader goal, but worth being explicit about.
If the intent is to replace online-check with the SW, this PR doesn't do that — README.md:628-632 still describes online-check as the recommended startup URL and nothing here adapts that flow. Either the two should co-exist deliberately (online-check remains the startup URL, SW makes the SPA itself resilient) or this PR should retire the page and migrate the docs.
2. How browsers cache the SW file (and how the PR holds up)
Browsers treat /sw.js specially in three ways relevant here:
- Update checks bypass the HTTP cache for the main script. With the default
updateViaCache: "imports"(the PR uses defaults atassets/client/index.jsx:15), the browser always fetchessw.jsfresh during update checks. Sub-imports (none here) would honor the HTTP cache. - Max-Age is capped at 86400 s. Even if a CDN sets a year of caching, browsers cap SW script caching at 24 hours. The nginx
Cache-Control: no-cacheis therefore belt-and-suspenders — correct, but largely redundant. - Updates fire on navigations in scope. A new
sw.jsis detected only when (a) a controlled page navigates, or (b)registration.update()is called. The PR does neither beyond initial registration. Combined withskipWaiting()+clients.claim()atscripts/sw-template.js:19,37, when a navigation finally happens the new SW takes over instantly mid-flight — but only on that navigation. Client screens may run for days without navigating.
Verdict against "ensure offline boot":
- The byte-comparison + content-hashed
BUILD_HASH(scripts/sw-template.js:5) means each build is detected as new, so updates aren't masked by HTTP caching. ✅ Service-Worker-Allowed: /with the script at root and scope/is correct.- The 24-hour cap is irrelevant to the offline-boot goal; it just affects when long-running screens pick up new builds.
3. Cache stability — what the PR can actually guarantee is in cache
Only what is reachable from the Vite manifest entry assets/client/index.jsx (scripts/generate-sw.js:14,26-39). Concretely from the current manifest:
assets/client-….js(entry)assets/client-….css_templates-….js,_setPrototypeOf-…,_esm-…,_chunk-…(transitive imports)assets/fallback-….png,assets/logo-….svg
What this does not include — and these are necessary for "offline boot":
| Asset | Status | Impact |
|---|---|---|
/client HTML (templates/client/client.html.twig) |
Not in PRECACHE; only added by networkFirst after the SW is active and a navigation occurs |
First offline boot fails entirely (see §4) |
/release.json |
Not precached | First offline boot can't read release; ReleaseLoader logs ERROR_RELEASE_FILE_NOT_LOADED but app continues |
/v2/authentication/screen, /v2/authentication/token/refresh, /v2/tenants/... |
Not handled by SW | App shell loads, then login fails offline |
Slide content (/media/..., feeds) |
Not handled by SW | No cached content to render |
| Custom templates dir | Empty in this checkout (eager glob — bundled if present); worth confirming for tenants that ship custom templates |
Slide templates from assets/shared/templates/*.jsx are loaded with import.meta.glob(..., { eager: true }) (assets/shared/slide-utils/templates.js:5-8), so they're inlined into the entry chunks and are in the manifest crawl. That's fine.
cache.addAll(PRECACHE_ASSETS) at scripts/sw-template.js:18 is atomic: any single asset failing aborts install, and skipWaiting() is chained after it, so a failed install also means no activate, no claim. Right semantic, but a transient 5xx on one asset during install means no offline support until the next install attempt.
4. What the PR does when assets are missing from cache
Per fetch handler (scripts/sw-template.js:67-91):
/clientnavigation, cache miss, online:networkFirstfetches and caches under/client. ✅/clientnavigation, cache miss, offline:networkFirstthrows on fetch, falls back to cache, cache is empty under/client→ re-throws → browser shows its native "no internet" page. App fails to boot./build/assets/...cache miss, online:cacheFirstfetches and caches. ✅/build/assets/...cache miss, offline:cacheFirstfalls through tofetch, throws → the chunk fails to load → React app errors out./release.jsoncache miss, offline:networkFirstthrows → ReleaseLoader setsreleaseTimestamp: null, app setsERROR_RELEASE_FILE_NOT_LOADEDbut continues. Survives.- API/media: handler returns nothing, default network fetch, fails offline → behavior depends on each consumer's own error handling.
The big one: first-boot offline gap
/client HTML is not part of PRECACHE_ASSETS and registration runs from index.jsx (i.e., after /client was already fetched without SW interception):
boot 1 (online):
GET /client ← uncached, no SW yet
GET /build/... ← uncached, no SW yet
SW registers, installs, addAll(PRECACHE) ✅, activate, clients.claim
→ cache contains /build/... but NOT /client
boot 2 (online): GET /client passes through SW networkFirst → cache /client ✅
boot 2 (offline before reload happened): cache miss for /client → broken
So the offline-boot guarantee requires the screen to be online for at least two successful navigations to /client, not one. This is exactly the situation online-check was designed to avoid (cached SPA loaded without network). Easy fix — add /client to the install-time cache:
// in install handler, decoupled from atomic addAll
await cache.addAll(PRECACHE_ASSETS);
const html = await fetch("/client", { credentials: "same-origin" });
if (html.ok) await cache.put("/client", html);Other things worth raising before merging
activatedeletes prior caches immediately (sw-template.js:23-39) whileclients.claim()swaps controllers mid-flight. Pages already loaded against the previous build's chunk URLs will, if they trigger any further dynamic load, hit the new SW with old URLs that are no longer in cache (and, becausevite build emptyOutDir, no longer on disk either) → 404. A kiosk that never closes the tab is more exposed than a desktop browser. Consider keeping the previous cache around until current clients are gone, or skippingclients.claim()on upgrade.- The PR does not surface install/activation failures anywhere. A screen with a permanently-failing SW install is silent. Consider posting a message to clients on install failure or wiring a status field.
- README still tells operators to set the startup URL to
/client/online-check. Either delete that page in this PR (and update README + CHANGELOG) or document how the two coexist.
TL;DR
- vs online-check: different feature with bigger surface area, with a real upside (cached content boot). Worth doing — but the PR doesn't reconcile docs/startup-URL with the existing page.
- Cache stability: only
/build/...assets reachable from the Vite manifest entry are guaranteed./clientHTML,release.json, and all/v2/...calls are not — and atomicaddAllis fragile to one-off network errors. - Offline boot: as written, the PR can't fully satisfy the stated goal on first boot.
/clientHTML is only cached after a SW-controlled navigation, so a screen that boots online once and then loses network has cached/build/...chunks but no HTML to load them from. Browser-special handling ofsw.jsis fine here; the gap is in what the SW itself caches at install time.
Link to ticket
https://leantime.itkdev.dk/#/tickets/showTicket/7398
Description
As supplement to online-check use service worker to cache assets and serve when offline on boot.
Things to consider:
see: https://developer.chrome.com/docs/workbox/service-worker-overview
Checklist
Service Worker for Offline Client App
Context
The client app runs on dedicated screen devices. In production, if a device loses internet after initial setup, it should still be able to load the client app from cache after a reboot with no internet.
The goal is: first load caches the app shell (HTML, JS, CSS, static assets), subsequent loads serve from cache, and the cache updates when new versions are deployed.
Approach: Hand-written Service Worker with build-time asset injection
Using a custom SW rather than
vite-plugin-pwa/Workbox because:vite-plugin-pwais designed for staticindex.htmlCache-Control: no-cacheensures the browser always checks for SW updates (browsers already cap at 24h, but explicit is better).How updates work
release.jsontimestamp, new hashed assets, newsw.js(different__BUILD_HASH__)sw.jschanged → installs new SW in background (precaches new assets)ReleaseService(assets/client/service/release-service.js) detects timestamp change → callswindow.location.replace()skipWaiting()was called) → serves new HTML + new assetsNo changes needed to
ReleaseService— it already handles the "when to update" problem.Verification
npm run build— confirmpublic/sw.jshas the manifest injected (no more placeholders)/clientin Chrome DevTools → Application → Service Workers — confirm SW is registered/client— confirm it loads from cache