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 startand 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 anssrroute falls back to the offline page, and a second build reaches the open page as a waiting worker thatuseAppUpdatereports by build id andapply()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 unsupporteduseAppUpdate 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 offlinerequestBackgroundSync() 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.