Installable apps and offline
pwa() writes the web app manifest, an offline page and a service worker from the build; caching rules per route, and what is never kept.
Add a web app manifest, an offline page and a service worker with the opt-in pwa() plugin. Choose what may be cached, then let the page offer an update when a new worker is ready.
Start with Work offline.
import { dusk, pwa } from "@shadow-js/dusk/vite";
export default {
plugins: [
dusk(),
pwa({
manifest: {
name: "Example Shop",
short_name: "Example",
start_url: "/",
display: "standalone",
theme_color: "#1d2b36",
background_color: "#ffffff",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
],
},
runtime: [
{ pattern: "/api/products/**", strategy: "stale-while-revalidate", maxEntries: 30, maxAgeSeconds: 3600 },
{ pattern: "/images/**", strategy: "cache-first", cacheName: "images", maxEntries: 60 },
{ pattern: "/api/feed", strategy: "network-first", networkTimeoutSeconds: 3 },
],
}),
],
};
The plugin does nothing in vite dev. Link the manifest from the document head with the metadata of the app
(metadata: { manifest: "/manifest.webmanifest" }; see metadata). If a metadata route already serves a
manifest, leave manifest out of pwa(): the build then writes no manifest and the route stays the only one.
What the build writes#
| File | Content |
|---|---|
sw.js |
The worker: the precache list, the rules, the runtime. Minified, one script, no imports. |
manifest.webmanifest |
manifest as JSON, with every address checked (the same check as the manifest route). Only when manifest is given. |
offline.html, offline.css |
The page shown when a navigation fails (below). Turn it off with offline: false. |
A generated file never replaces a file of the same name in public/: the build fails and names it, before anything is
written. serviceWorkerFile, manifestFile and offline.file rename the outputs. The Vite base must be a path (/ or
/app/): a service worker only handles its own origin. Under a mounted app the worker is at /app/sw.js with the scope /app/,
and the rule patterns are matched against the path below the base.
Precache#
At install the worker downloads the files of the build's assets folder (the hashed bundles, styles and fonts) and the offline
page. Each entry carries a revision, the first 12 digits of the SHA-256 of the file. The caches are named
<prefix>-precache-<build id> (the prefix is dusk unless cacheNamePrefix says otherwise), where the build id is a hash of
the precache list, or the buildId you give. So:
- An unchanged build has the same worker, the same id and the same caches.
- A changed build installs next to the old one. Files whose revision is the same in the old cache are copied, not downloaded again. If any file fails to download, the install fails and the old worker keeps serving.
- When the new worker activates it deletes every cache of this app that carries another build id.
precache.include and precache.exclude are globs of paths in the output folder (assets/** by default); a file over
precache.maxFileBytes (2 MiB) is left out with a warning. A precached file is answered from the precache, found by its exact
URL; the same URL with a query string goes to the network.
Runtime rules#
A rule matches a path below the base with one or more globs (* inside a segment, ** across segments, {a,b}). The first rule
that matches a request wins.
| Strategy | Answer |
|---|---|
network-first |
The network, kept for later. When the network fails, or is slower than networkTimeoutSeconds, the kept answer. |
cache-first |
The kept answer; the network on a miss. Offline, an answer past its age is used when it is all there is. |
stale-while-revalidate |
The kept answer at once, refreshed in the background; the network on a miss. |
Each rule has its own cache (cacheName, default rule<position>), maxEntries (50; the oldest entries are deleted first, 0 is
no limit), and maxAgeSeconds (30 days; 0 is no limit). A rule that matches a page navigation caches that page; without one, a
navigation always goes to the network and is never kept.
Offline#
A navigation that fails because the network is gone is answered by, in order, the appShell (a path of the app that is
precached at install, so the server must answer it with a 200) and the offline page. Without either, the browser shows its
own error. The offline page has no script and no inline style, so a strict Content-Security-Policy leaves it intact; its text
and language come from offline.
Never kept#
The worker handles only same-origin GET requests inside the base. These always go to the network:
- Any other method: server functions, form posts and every write.
- Paths in
exclude(/_dusk/**by default: server functions, the navigation gate, the image optimizer, streams). - A request with an
Authorizationheader, or one that asks for a byte range.
And these responses are never put in a cache, whatever the rule says:
- A status other than
200, a redirect, or an opaque or cross-origin answer. Cache-Control: no-storeorprivate. Dusk sendsprivate, no-storeunless a route sets its own lifetime, so a page or an API answer is kept only when its author said it may be.Vary: CookieorVary: *, and aSet-Cookie. A service worker cannot readSet-Cookie(the browser hides it from scripts), so the rule that protects session answers is theCache-Controlabove: do not point a rule at an endpoint that sets a cookie and also sayspublic.- A
Content-Security-Policythat carries a nonce. A nonce is for one response; replaying it from a cache would defeat the policy, so such a page is fetched again every time.
The worker is registered with updateViaCache: "none", so the script itself is always checked against the network. sw.js is
written to the root of the output, outside the immutable assets folder, and the adapters serve a file there with
max-age=0, must-revalidate.
The page side#
import { onMount } from "@shadow-js/core";
import { registerServiceWorker, useServiceWorkerUpdate } from "@shadow-js/dusk/features/pwa";
export default function App(props: { children?: unknown }) {
const { waiting, update } = useServiceWorkerUpdate();
onMount(() => {
void registerServiceWorker();
});
return (
<>
{props.children}
{waiting() ? (
<button type="button" onClick={update}>
A new version is ready. Reload
</button>
) : null}
</>
);
}
registerServiceWorker({ base, url, scope, dev }) does nothing on the server, in a browser without service workers, and in
development unless dev: true. It waits for the load event. It writes no inline script, so it needs no nonce; the policy
must allow the worker (worker-src 'self', which default-src 'self' already gives).
A new build installs in the background and then waits: the old worker keeps serving the open tabs, so a page never runs new
markup against old cached files. useServiceWorkerUpdate() returns { waiting, update }. waiting() is true while a new
version is installed and waiting. update() tells the waiting worker to take over and reloads the page once it does. The worker
never skips waiting on its own; the only message it accepts is the one update() sends.
unregisterServiceWorker(prefix) removes the worker of the origin and the caches that start with the prefix. Use it as a
kill switch or when a user signs out on a shared device.
Cache behavior#
The service worker keeps only responses allowed by the configured caching strategy. Entry and age limits bound each cache; excluded routes and responses that must remain private stay outside it. A waiting update takes over only when the page asks it to activate.