PWA: installable app + offline shell
uihost.WithPWA turns a UIHost app into an installable Progressive Web
App: it serves a typed web app manifest, a CSP-safe external service
worker with a versioned app-shell precache, an offline fallback screen,
and the registration script. Apps opt in with one option: no hand-wired
head tags, caching rules, or registration scripts.
site := app.NewApp("Meridian") // ... register screens ... fwApp.Mount(uihost.New(site, uihost.WithStaticDir("static"), uihost.WithPWA(uihost.PWAConfig{ ShortName: "Meridian", ThemeColor: "#4f46e5", Icons: []uihost.PWAIcon{ {Src: "/icons/icon-192.png", Sizes: "192x192", Type: "image/png"}, {Src: "/icons/icon-512.png", Sizes: "512x512", Type: "image/png"}, {Src: "/icons/icon-maskable.png", Sizes: "512x512", Type: "image/png", Purpose: uihost.PWAIconPurposeMaskable}, }, }), ))
Chromium's installability check needs HTTPS (or localhost), a manifest
with a name, and 192px + 512px icons: supply the icon files as static
assets, or skip file management entirely: uihost.WithAppIcon(logo)
derives the 192/512 manifest icons (plus favicon and apple-touch-icon)
from one source image when Icons is left empty (see SEO).
Generated apps scaffold placeholder icons automatically (see
App specs, app.pwa).
What gets mounted
| Route | Purpose | Headers |
|---|---|---|
/manifest.webmanifest | The web app manifest, generated from PWAConfig via encoding/json (all values escaped) | application/manifest+json, no-cache |
/service-worker.js | The generated worker. Served from the root so its default scope covers the whole app | application/javascript, no-cache (browsers re-fetch it to detect updates) |
/__gofastr/pwa/register.js | External registration script, injected on the deferred script rail at the end of <head> on every page; CSP-safe under the default default-src 'self' policy (no inline JS) | application/javascript, no-cache |
/__gofastr/pwa/offline | The offline fallback page, precached at install time | text/html, no-cache |
Every rendered page also gains <link rel="manifest"> and, when
ThemeColor is set, a <meta name="theme-color"> in <head>.
In an app with locale prefixes (framework.WithLocalePrefixes), a page
under /es/ links /es/manifest.webmanifest, whose start_url is the
configured one with the prefix (/ becomes /es, /app becomes
/es/app). An absolute StartURL on another site is left alone. id
and scope are the same in every locale, so the browser treats each
locale's manifest as one app and a reader who switches language stays
inside it. The worker precaches each locale's manifest.
Without WithPWA none of these routes or tags exist; existing apps
are unchanged.
PWAConfig
| Field | Default | Notes |
|---|---|---|
Name | the core-ui app title (app.NewApp(name)) | Manifest name |
ShortName | omitted | Home-screen label |
Description | omitted | |
StartURL | / | |
Scope | / | |
ID | StartURL | Stable app identity across renames |
Display | PWADisplayStandalone | Also PWADisplayFullscreen, PWADisplayMinimalUI, PWADisplayBrowser |
ThemeColor / BackgroundColor | omitted | ThemeColor also emits the head meta tag |
Icons | none | PWAIcon{Src, Sizes, Type, Purpose}; purposes: PWAIconPurposeAny, PWAIconPurposeMaskable, PWAIconPurposeMonochrome |
Precache | none | Extra same-origin paths to keep available offline |
OfflineScreen | framework default | A component.Component rendered at /__gofastr/pwa/offline |
DenyPaths | none | App-specific mounts to add to the sensitive-path deny list (e.g. a CRUD API at a custom prefix); never precached, never intercepted |
The caching model
The generated worker is deliberately conservative:
- Documents are network-first and never cached. Rendered HTML can be personalized, so navigations always hit the server; when the network is unavailable the precached offline screen renders instead.
- The cache holds exactly the precache set: the goFastr runtime
(
runtime.js, split modules under their content-addressed?v=<hash>URLs,color-scheme.js,actions.js,app.css,manifest.js, one per catalog locale in an app with a translator), the web app manifest, the offline page plus the per-component stylesheets it links, declared icons, and yourPrecacheentries. The live worker lists the shell assets under both their bare paths (the offline page references those) and the?v=<hash>forms live pages request; a static export's worker lists bare paths only, matching its query-free files. Nothing is ever added to Cache Storage at runtime, and precached responses are re-wrapped at install time so a static host's redirects can't poison the offline fallback. - Online always means fresh. URLs are matched exactly. Only
content-addressed assets (
?v=<hash>) are served cache-first; they're immutable, and a new deployment's URLs miss the old cache. Every other asset is network-first, with the cache answering only when the network is gone, so after a deploy, new HTML never runs against the previous deployment's runtime or CSS. - Sensitive endpoints are never intercepted and can never be
precached.
/__gofastr/sse,/__gofastr/session,/__gofastr/action,/__gofastr/widgets,/__gofastr/widget/(per-screen action scripts are session-gated),/core-ui/widget/*(per-user widget state and chrome),/api/*, and/auth/*are on a deny list baked into the worker;Precacheentries that point at them (or at another origin) are dropped. Apps that mount their API or auth elsewhere extend the list withDenyPaths(generated apps do this automatically for a customapi_prefix/ authbase_path). - Cache names are versioned deterministically. The name is
gofastr-pwa-<slug>.<name-hash>.<fingerprint>;<name-hash>is 12 hex chars derived from the app name, and the fingerprint hashes the manifest, the precache URL list, the shell asset bodies, and the bytes of every precache entry served from the host's static storage; swapping an icon in place rotates the version. A new deployment installs a fresh cache; on activation the worker deletes only obsolete caches carrying this app'sgofastr-pwa-<slug>.<name-hash>.prefix, and reaps leftovergofastr-pwa-<slug>-<hex>caches from the older dashed naming. (An asset served from outside static storage, say a reverse proxy, contributes only its URL; give it a new path or query when it changes.)
Static exports: the full-site worker
The rules above protect a LIVE app, where rendered HTML can be
Personalized. A static export has no such concern:
the page set is closed and every byte is immutable, so the exported
service-worker.js flips strategy: the whole site is precached at
install: every page, every framework asset (including component
stylesheets under their versioned ?v= URLs), and the shell.
Navigations are served cache-first, tolerating static hosts' trailing-
slash redirects, with the network as fallback. Visit the site once,
install it, and every page works offline after that, even ones you
never opened.
User static-dir files are precached best-effort: one un-servable file
does not fail the install and pin clients to a previous deployment.
The cache version fingerprints the exported content, so any redeploy
(even a text-only edit) rotates the cache through the normal
worker-update cycle, and the static worker's caches use a separate
gofastr-pwa-static-… prefix carrying the base path, so a live
deployment and static exports on one origin never delete each other's
caches. The deny list (/api, /auth, framework session/SSE paths,
plus your DenyPaths) applies unchanged. If the app's static dir
ships its own manifest.webmanifest or service-worker.js, the
export keeps the user-supplied file.
Offline screen
The default screen is a minimal "You're offline" notice rendered
through the standard document shell (theme bootstrap, app.css), so it
follows your theme. Pass OfflineScreen to replace it:
uihost.WithPWA(uihost.PWAConfig{OfflineScreen: myOfflineScreen{}})The page is precached at service-worker install time, so it must not render personalized content; for that reason it is deliberately not wrapped in the app layout (a layout may embed per-user chrome). A custom offline screen renders behind panic containment: if its render panics, the default offline page is precached instead. (This differs from a registered screen's render panic, which answers 500 with a logged error line. The offline screen is precached at install time, not served per request, so a fallback page is the only possible answer.)
The assets the offline page loads (its src attributes and <link
href> stylesheets, root-relative) are precached with it. Its
navigation links (<a>, <area>, <base>) are not: a page they name
is fetched with the installing user's cookies.
Update behavior
Registration runs on every page load, which doubles as the update
check. A new deployment installs its worker and cache in the
background, but the framework never calls skipWaiting; the new
worker activates once every tab running the old version closes, so an
open app is never yanked onto new code mid-session. To offer a "new
version available — reload" prompt, listen for the window event the
registration script dispatches when an update is installed and waiting:
window.addEventListener("gofastr:pwa-update", function (e) { // e.detail.registration is the ServiceWorkerRegistration showUpdateBanner(); });
(Ship that listener as an external script via WithExtraScripts; the
default CSP blocks inline JS.)
Static export
App.ExportStatic emits all of the PWA files alongside the pages:
manifest.webmanifest, service-worker.js,
__gofastr/pwa/register.js, and __gofastr/pwa/offline/index.html,
plus <locale>/manifest.webmanifest for each prefixed locale tree.
Under a non-root BasePath (e.g. a GitHub Pages project site) the
manifest's start_url/scope/id/icon paths, the worker's precache
list and deny list, and the registration target are all prefixed, so
the exported app installs and works offline from the subpath. See
Static-site export.
App spec support
gofastr generate scaffolds the whole PWA setup described above from an
app.pwa block, including replaceable placeholder icons. See
App specs.
Common mistakes
- Declaring
Iconsthat don't resolve. Icon paths are added to the precache; if a file 404s,cache.addAllrejects and the worker never installs. Put the files in your static dir first, then declare them. - Precaching API responses.
Precache: []string{"/api/products"}is silently dropped: the shell cache is for assets, never data. If you need offline data, that's application logic, not the app shell. If your API lives at a custom prefix, list it inDenyPathsso the same guarantee covers it. The entry is decoded and cleaned before the check, as the router does, so/a%70i/productsand/static/../api/productsare dropped too, and an entry with a malformed escape is dropped. - Expecting pages to work offline. Only the shell and the offline screen are cached. Navigations are network-first by design; rendered pages are never stored (they can be personalized). An offline visit to any page shows the offline screen.
- Forcing updates with
skipWaitingfrom your own script. The no-forced-reload behavior is deliberate; if you must switch immediately, prompt the user from thegofastr:pwa-updateevent and reload after they accept. - Rendering per-user content into a custom
OfflineScreen. The page is cached at install time under the installing user's session; keep it generic.