Installable web apps (PWA)

pwa in app.config.ts — the build writes the manifest, the icon set and a service worker whose caching follows each route's renderMode; useInstallPrompt, useAppUpdate, useOfflineStatus and useBadge in @voltro/web read what was written.

Any web app becomes installable with one block in app.config.ts. No template kind, no copied files: voltro build writes manifest.webmanifest, the icon set and sw.js beside its other artefacts, the document links them, and the hooks below read what was written.

Verified in a real browser (2026-09-26). The framework's browser check builds a fixture with this block, serves it with voltro start and asks Chromium: the manifest resolves with its icons, the worker registers and takes control of the page, the static home page reloads offline from the precache while an ssr route falls back to the offline page, and a second build reaches the open page as a waiting worker that useAppUpdate reports by build id and apply() swaps in. The Background Sync tag is asserted where full Chromium runs.

// app.config.ts
export default {
  type: 'web' as const,
  name: 'orders',
  pwa: {
    name: 'Acme Orders',
    shortName: 'Orders',
    themeColor: '#0b0b0f',
    icon: 'public/icon.svg',      // default — an SVG or a ≥512px PNG; the build rasterises 192/512/maskable/apple
    offline: 'shell',             // 'shell' | 'fallback-page' | 'none'
    update: 'prompt',             // 'prompt' | 'auto-on-idle' | 'manual'
    shortcuts: [{ name: 'New order', url: '/orders/new' }],
  },
}

What the build writes

Artefact From
manifest.webmanifest the pwa block + the app's locale
icons/icon-192.png, icon-512.png, icon-512-maskable.png (subject inside the 80 % safe zone), apple-touch-icon.png pwa.icon, through sharp
__offline/index.html the app name — the page an ssr route falls back to
sw.js the hashed assets, the prerendered pages and every route's renderMode

The document head gains the manifest link, theme-color, the Apple meta tags (apple-mobile-web-app-status-bar-style is default) and the touch icon in every mode. A page's own <meta> of the same name — a black-translucent status bar in a dark app's meta.tags, say — replaces the shell's, so the document carries one. The worker is registered by mount() only outside voltro dev (a worker answering navigations from a cache while Vite hot-reloads them is not a dev loop). In dev the manifest is served from the same config, so an install prompt can be tried.

Caching follows renderMode

The worker never caches data — the subscription is the source and the local-first mirror is the offline copy. It caches the shell and assets, and what it does on a navigation depends on the page's render mode:

renderMode offline: 'shell' navigations
spa the app shell (/) is precached; the app opens offline shell from cache
static prerendered pages up to 256 KiB are precached cache first, then network
ssr only the offline page network first, offline page as fallback
isr only the offline page network first with a 3 s timeout, then the last fetched copy

Hashed assets under /assets/ and the icons are cache-first (their hash is their version). /_voltro/*, /ws/* and /auth/* never go through the worker. offline: 'fallback-page' precaches only the offline page; 'none' writes the manifest and no worker at all.

Hooks (@voltro/web)

import { useAppUpdate, useBadge, useInstallPrompt, useOfflineStatus } from '@voltro/web'

const { canInstall, install, platform } = useInstallPrompt()
// platform: 'prompt' (Chromium offers one) | 'ios-manual' (Safari: share sheet) | 'none'

const { updateAvailable, version, apply, dismiss } = useAppUpdate()
// under update: 'prompt' nothing happens until apply(); the page reloads when the new worker takes over

const { offline } = useOfflineStatus()   // the platform's reachability signal
useBadge(unreadCount)                    // Badging API; a no-op where unsupported

useAppUpdate is the same shape a desktop shell's updater and an Expo OTA channel expose — all three are the platform adapter's updates port (platform adapter), so a "new version" banner is written once.

Web push

With pwa set, the worker voltro build writes already carries the notifications plugin's push handlers: a pushed message shows as a notification, a click opens url or data.deepLink, and open pages receive { type: 'voltro:push' }. useWebPush() registers the default /sw.js — no file to copy.

Store wrappers and the audit

voltro pwa audit apps/acme/web                                    # after voltro build: manifest, icons, worker, offline page, head tags
voltro pwa twa apps/acme/web --host app.example.com --package com.example.app --fingerprint AB:CD:…

audit is the static half of a Lighthouse PWA audit, run without a browser; an error is an install blocker, a warning a polish item. twa writes Bubblewrap's twa-manifest.json for a Trusted Web Activity on Google Play from the same pwa block; voltro mobile links writes the matching assetlinks.json from the same fingerprint. The Microsoft Store takes the manifest URL directly (PWABuilder). The Apple App Store accepts no plain PWA — that is the Expo track. pwa.protocolHandlers and pwa.shareTarget land in the manifest either way.

The update impulse

With @voltro/plugin-releases on the api, the app does not poll for a new worker: useUpdateImpulse({ app, channel }) subscribes to the channel's release policy and, when a newer version is rolled out to this install, asks the platform's update channel to check — the service worker here, the desktop updater or the OTA client on the other shells. useAppUpdate then reports it the same way it does today.

Background Sync — draining the outbox with the tab closed

Chromium can wake the worker when connectivity returns and let it finish what the page could not. Opt in with the api's rpc path on this origin:

mount({ pwa: { name: 'Acme', backgroundSync: { rpcUrl: '/_voltro/api/main/rpc' } } })

The build then ships /voltro-sw-outbox.js — @voltro/client's drainOutboxInWorker, bundled from the client your app installed — and the worker answers the voltro:outbox sync tag by loading the queue from IndexedDB, posting each replayable entry as the rpc Request envelope and saving the transitions back (sent, failed, conflict, the same states useOutbox shows). Each request carries the entry id as its idempotency key — the same key the page's own useOutbox({ api }) sends — so a write the page and the worker both deliver, or one resent after a lost answer, runs once where the api has idempotency configured. For that to work the page must keep its outbox where the worker can read it, and ask for the sync:

import { indexedDbOutboxPersistence } from '@voltro/client'
import { useBackgroundSync } from '@voltro/web'

const outbox = useOutbox({ send, persistence: indexedDbOutboxPersistence() })
useBackgroundSync(outbox)   // registers the sync whenever writes are pending offline

requestBackgroundSync() returns false where the browser has no Background Sync (Safari, Firefox) — nothing changes there: the page replays on its next open, as before. The worker sends with no session cookie of its own beyond what the browser attaches to same-origin requests; a header-based session is passed with drainOutboxInWorker({ headers }) if you customise the bundle. Verified against fakes (the drain, the IndexedDB protocol, the worker source); a real Chromium run of the sync event is the browser proof still open in the programme plan.

The devtools panel

The in-page devtools overlay (@voltro/devtools, voltro dev) has a PWA tab: the worker's three slots (installing / waiting / active) with its scope and script, whether Background Sync exists in this browser, the voltro-* caches with their entry counts, the current build against the waiting one with Check now and Force update (applies over pending writes — the same apply({ force: true }) the hook offers), installability and install state, and the push permission with the subscription's push-service host (never its endpoint path). Every fact comes from the tab's own browser globals; the api-side push subscriptions are the notifications panel's, which follows the four-layer dashboard parity.