📲 @ultimat3/pwa
You never open sw.js. It is emitted from the route table. That is this package's
whole thesis: a hand-written service worker encodes routing decisions a second time, and
the second copy is the one nobody updates.
const { source, precache, warnings } = generateServiceWorker(describePages(), config, buildId);
Browser entry: @ultimat3/pwa/client
As of 26.1.0. Everything a page or an island calls comes from @ultimat3/pwa/client, never
from @ultimat3/pwa: the barrel reaches the icon pipeline (core's image transform, Node-only), the
service-worker generator and the push sender, and an island importing it does not build.
import {
detectSkew,
installController,
MIN_ENGAGEMENT_MS,
subscribeToPush,
} from '@ultimat3/pwa/client';
import { APP_UPDATE_MESSAGE, CLIENT_BUILD_META } from '@ultimat3/core/page';
| the install prompt, never on first paint | installController (+ InstallOptions, InstallHost, InstallController, InstallOutcome, BeforeInstallPromptEventLike, ReadSignal), iosInstallGuidance (+ IosGuidance), MIN_ENGAGEMENT_MS (30 000 — the default minEngagementMs) |
| "a new version is live" | AppUpdateAvailable (the worker's message), detectSkew (+ SkewState). The message's type value is core's APP_UPDATE_MESSAGE, from @ultimat3/core/page — one name, one home |
| Web Push | subscribeToPush, unsubscribeFromPush, pushPermission, browserPushHost, PUSH_KEY_META (+ their types) |
Its graph is three modules of this package plus @ultimat3/core/page; client-bundle.test.ts
bundles it for the browser and refuses a fourth, an image module or a node: import. The server
barrel keeps exporting the same names for server code and tests.
Render mode → runtime strategy
static | network-first | the document names this deploy's hashed assets; the precache is the offline copy |
isr | network-first | the server's ISR cache is already the stale one; a browser copy only delays a deploy |
stream | network-first | the shell names this deploy's chunks; the cache is the offline copy |
ssr | network-first | freshness is the point; cache is the offline safety net |
Every rule is a document, so every mode is network-first (22.3.2): static was cache-first and
isr/stream stale-while-revalidate, and an online visitor got the previous deploy's HTML until a
hard reload. offline: 'precache' still precaches — for offline. The worker enables navigation
preload on activate and answers a navigation from event.preloadResponse, and fills its precache
PRECACHE_CONCURRENCY entries at a time. The worker calls skipWaiting()
in install, so a deploy takes over without every tab closing; it never reloads a page.
Overrides: offline: 'network-only' forces network-only; a per-route strategy wins over
everything. api/ routes get no cache rule at all.
precache | fetched at install, keyed by content hash |
runtime | cached on first visit under the render-mode strategy |
network-only | never served from a cache |
Version skew — the thing that actually breaks PWAs
A client loaded build A hours ago. Build B deletes A's chunks. The next lazy import 404s
and the app dies with a blank screen and no error anyone can act on.
| Build id | immutable per deploy, derived from the commit sha; X_BUILD_ID_MISSING if absent |
| Client → server | every SW-proxied request carries x-ultimate-build — @ultimat3/core's BUILD_ID_HEADER |
| Retention | retentionPlan(deploys, keep) keeps the last N deploys' assets alive (default 3) |
| Stale client | gets AppUpdateAvailable, never a 404 |
| Forced reload | none. This package never navigates a client — the app decides what to do with the message |
| Preview deploys | cache names are x-<kind>-<buildId>, so a branch build cannot poison production |
detectSkew(clientBuildId, message.to);
unknown means no id was sent — a first load or a crawler — and is never treated as stale.
The offline fallback is mandatory in the type
X_PWA_NO_OFFLINE_FALLBACK: no offline fallback route
cause: app.config.ts has no `offline` block, so an offline navigation would show the browser's error page
fix: set pwa: { offline: { fallback: '/offline' } } in app.config.ts, then create the route it names: x g route offline --surface site
requireOfflineFallback(config) runs inside generateServiceWorker, so the build fails
before an un-shippable PWA exists. pwa.offline.image and pwa.offline.font are the
placeholders a failed image or font request gets — each served only from the precache.
Capabilities are opt-in, and gate bytes
push | — | push + notificationclick listeners; a tap opens a same-origin URL only. Emitted only when a vapid key comes with it — the web role passes installedVapid() |
backgroundSync | — | sync listener that tells every open tab to drain realtime's outbox (OUTBOX_DRAIN_MESSAGE) |
badging | — | navigator.setAppBadge after a push |
shareTarget | share_target | — |
fileHandlers | file_handlers | — |
protocolHandlers | protocol_handlers | — |
A disabled capability emits neither the manifest member nor the SW code. An unused
capability ships zero bytes and asks for zero permissions.
Three of the six are manifest-only, and the — in their SW column is load-bearing: the OS hands a
share, a file or a protocol URL to a route the app already serves, so there is no worker branch to
gate. CAPABILITY_SW_MARKERS is checked against the emitted sw.js in both directions, so a claim
here that the generator does not honour is a failing test rather than an installed app announcing a
capability nothing implements.
Web Push
pwa: { push: true, vapid: { subject: 'mailto:ops@example.com' } } in app.config.ts, and the rest
is wiring the framework does: the boot resolves the VAPID pair from the environment and installs the
runtime, the worker carries the handlers, every page carries <meta name="x-push-key">. RFC 8291
encryption and RFC 8292 (ES256) signing, on WebCrypto — no dependency.
import { pushSubscribe, pushUnsubscribe } from '@ultimat3/pwa';
export const subscribePush = pushSubscribe({ permission: 'push:subscribe' });
export const unsubscribePush = pushUnsubscribe({ permission: 'push:subscribe' });
import { subscribeToPush } from '@ultimat3/pwa/client';
declare const client: { subscribePush(input: unknown): Promise<unknown> };
const outcome = await subscribeToPush({ save: client.subscribePush });
import { pushChannel } from '@ultimat3/notify';
import { webPush } from '@ultimat3/pwa';
export const channel = pushChannel<{ postId: string }>({
pusher: webPush(),
message: ({ event }) => ({
titleKey: 'push.comment.title',
bodyKey: 'push.comment.body',
url: `/posts/${event.params.postId}`,
tag: `post:${event.params.postId}`,
}),
});
| Keys | ULTIMATE_VAPID_PUBLIC_KEY + ULTIMATE_VAPID_PRIVATE_KEY, env only. x vapid create seals both in one write; locally, neither set signs with DEV_VAPID_KEYS, which a deployed boot refuses (X_PWA_VAPID_KEY_MISSING). Two halves that do not sign for each other: X_PWA_VAPID_KEY_INVALID, at boot |
| Subscriptions | x_push_subscriptions, applied by the boot; keyed by endpoint, stored against ctx.actor.id, with the locale and zone they subscribed in. An agent never subscribes |
| Strings | catalog keys (titleKey, bodyKey, actions[].titleKey), rendered per subscription locale through @ultimat3/i18n's translatorFor |
| Size | one 4096-byte record: 3993 bytes of notification (X_PWA_PUSH_PAYLOAD_TOO_LARGE). Send a path, never the content |
| Travel | ttlSeconds (default 86 400), urgency, topic — RFC 8030 headers; the topic is any string, sent as 32 URL-safe characters of its SHA-256 |
| 201 / 404, 410 / 429, 5xx / 400, 401, 403, 413 | delivered / subscription deleted / X_PWA_PUSH_FAILED, retried by the job after every other device was tried / X_PWA_PUSH_REJECTED, logged, never retried — and on 401/403 the subscription is deleted: it was made with a key this server no longer signs with |
| Key rotation | subscribeToPush compares a browser's existing subscription with the page's x-push-key; one made with another key is unsubscribed and replaced, never re-saved |
| Endpoints | dialled only on a push service: https:, and a host that is one of PUSH_SERVICE_HOSTS (FCM, Mozilla, Apple, WNS) or pwa.vapid.pushHosts, or a subdomain of one on a dot boundary — checked when stored and before every send (assertPushEndpoint), never by where the name resolves, since the endpoint came from a request body (X_PWA_PUSH_HOST_UNLISTED; a stored one is deleted unsent). One POST waits at most PUSH_SEND_TIMEOUT_MS (30 s); a caller's abort is rethrown as is, never the retryable class |
renotify | dropped, with a warning, without a tag — at both ends |
Error classes
Every error class src/index.ts exports, for instanceof inside one process. Across a wire or
a job boundary the class is gone and the code is what survives — match on that.
BuildIdMissingError | X_BUILD_ID_MISSING | src/errors.ts |
PwaIconMissingError | X_PWA_ICON_MISSING | src/errors.ts |
PwaManifestInvalidError | X_PWA_MANIFEST_INVALID | src/errors.ts |
PwaNoOfflineFallbackError | X_PWA_NO_OFFLINE_FALLBACK | src/errors.ts |
PwaPushFailedError | X_PWA_PUSH_FAILED | src/errors.ts |
PwaPushPayloadTooLargeError | X_PWA_PUSH_PAYLOAD_TOO_LARGE | src/errors.ts |
PwaPushRejectedError | X_PWA_PUSH_REJECTED | src/errors.ts |
PwaPushSubscriptionInvalidError | X_PWA_PUSH_SUBSCRIPTION_INVALID | src/errors.ts |
PwaPushUnconfiguredError | X_PWA_PUSH_UNCONFIGURED | src/errors.ts |
PwaStrategyExhaustedError | X_PWA_STRATEGY_EXHAUSTED | src/errors.ts |
PwaVapidKeyInvalidError | X_PWA_VAPID_KEY_INVALID | src/errors.ts |
PwaVapidKeyMissingError | X_PWA_VAPID_KEY_MISSING | src/errors.ts |
SwScopeInvalidError | X_SW_SCOPE_INVALID | src/errors.ts |
Public API
generateServiceWorker | sw.js from the route table; deterministic for identical input |
strategyFor, MODE_STRATEGY, cacheFirst, … | the four strategies + the mapping table; a personal route is network-only. Each answers what its emitted sw.js twin answers: the cache copy goes to env.wait and is never awaited (a failed copy costs the copy, not the response), and an exhausted strategy with no fallback rejects — staleWhileRevalidate with X_PWA_STRATEGY_EXHAUSTED |
routeRules, assetRules | the worker's rule list: routes most specific first, runtime asset prefixes (/islands/) ahead of them. routeRules(routes, personalPages) — 'last-member' gives a personal page a pages rule; a pattern matches the browser's percent-encoded pathname, and a catch-all its bare prefix |
CLEAR_PAGES_MESSAGE, PAGES_CLEARED_MESSAGE | { type: 'clear-pages' } — post it to the worker on sign-out; it empties every pages cache and answers { type: 'pages-cleared' } |
buildPrecacheManifest | precache entries (url + content-hash revision), size warnings |
buildId, detectSkew, retentionPlan | version skew (detectSkew also on ./client) |
generateWebManifest | the manifest + theme-color metas for both schemes, from a WebManifestInput. Called by @ultimat3/cli (pwa-artifacts.ts) As of 2026-08-27, so x dev, the container and the static export all emit manifest.webmanifest |
planIcons, requireSourceIcon, maskableSafeZone | icons and splashes from one source |
BuiltinImagePipeline | renders that plan: one square PNG per entry, deterministic |
requireOfflineFallback | the mandatory offline route |
backgroundSyncSource, registerBackgroundSyncSource | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
renderPushPayload, pushSource, subscribeSource | Web Push, per-locale bodies |
pushSubscribe, pushUnsubscribe | factories over action: store / forget this browser's subscription for ctx.actor |
webPush, pushToActor | one notification to every device of one person — webPush() is notify's Pusher |
sendPushMessage, encryptPushMessage, vapidAuthorization | one message to one subscription; RFC 8291; RFC 8292 |
installWebPush, installedVapid, webPushRuntime, resetWebPush | the runtime the boot installs (store, signer, transport, translator) |
memoryPushSubscriptionStore, postgresPushSubscriptionStore | where subscriptions live; x_push_subscriptions DDL is @ultimat3/pwa/schema |
resolveVapidKeys, generateVapidKeys, assertVapidPair, DEV_VAPID_KEYS | the key pair: from env, minted, checked, and the published development one |
@ultimat3/pwa/client | THE browser entry — Browser entry |
installController, iosInstallGuidance, MIN_ENGAGEMENT_MS | install prompt, never on first paint (also on ./client) |
PwaStrategyExhaustedError and the other errors.ts classes | the codes this package throws, catchable by an app |
Notes
- Theme colours come from the design tokens for both schemes. The manifest spec carries
one
theme_color, so the dark value is emitted as a media-scoped
<meta name="theme-color"> — otherwise an installed dark app launches with a light status
bar every time. The shape is @ultimat3/core's PwaColors, which is what an app writes in
app.config.ts as pwa.colors; this package declared its own identical ThemeTokens until
2026-08-27, with nothing asserting the two agreed.
WebManifestInput is not the pwa block of app.config.ts. It was called PwaConfig and
said it was, while @ultimat3/core exported a different type of that name that really is the
block. An app writes name and colors; every other member is a caller's.
- The service worker has a build behind it,
As of 2026-08. generateServiceWorker — and
through it buildPrecacheManifest, offlineFallbackSource, backgroundSyncSource and
pushSource — is called by packages/cli/src/sw-artifacts.ts, so x dev, the container and the
static export all emit sw.js and x-sw-register.js
(#390). It landed a release after the
manifest half because a bad sw.js is sticky: it waited on a real browser check
(packages/cli/e2e/service-worker.e2e.test.ts), which the tree could not run until #400.
- Precache revisions are content hashes, never the build id. Keying on the build id
re-downloads every asset on every deploy.
- The mutation queue lives in
@ultimat3/realtime, not here (SRP). This package owns
only the Background Sync trigger that tells the open tabs to drain it.
- Push bodies are rendered server-side per subscriber locale, from the locale stored on
the subscription. A notification in the wrong language is a real bug, and the sending
server has no request context to infer one from.
- Icons come from one source image.
X_PWA_ICON_MISSING names the file to add;
BuiltinImagePipeline renders the whole matrix from it through @ultimat3/core's image
pipeline — no sharp, no vendor image CDN, no native build step. Every output is a square
PNG, because type: 'image/png' is what the manifest declares. A maskable icon's artwork
lands exactly inside maskableSafeZone(size); the ring around it is background, which is
hex or transparent (there are no named colours). Same bytes in, same bytes out.
- Every HTML sink goes through one escaper.
appleTouchLinks and renderThemeColorMeta
interpolate app configuration into attributes, so both run it through escapeHtml from
@ultimat3/core — the framework's one HTML character table (@ultimat3/render's html.ts is
tier 4, sideways, and re-exports the same function). Never a second escaper here.
- A precache URL may already carry a query.
PrecacheAsset.url is public API and bundlers emit
?v=<hash> of their own, so the install block picks ? or & per entry. A fixed ? produced
...?locale=en?v=<rev>, and because cache.addAll is all-or-nothing a single non-200 there means
the worker never installs at all.
- Route data arrives as data.
@ultimat3/render and @ultimat3/pwa are both tier 4, so
PwaRoute is a structural view of RouteDescriptor, never an import.