[]previous and next page

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.

For agentsUpdated

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.

vite.config.tsts
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 Authorization header, 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-store or private. Dusk sends private, no-store unless a route sets its own lifetime, so a page or an API answer is kept only when its author said it may be.
  • Vary: Cookie or Vary: *, and a Set-Cookie. A service worker cannot read Set-Cookie (the browser hides it from scripts), so the rule that protects session answers is the Cache-Control above: do not point a rule at an endpoint that sets a cookie and also says public.
  • A Content-Security-Policy that 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#

src/app.tsxtsx
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.