# Design: `@suaveplan/pwa` > Non-normative companion to `./proposal.md` and `./specs/pwa/spec.md`. ## File layout ```text packages/foundation/data/pwa/ package.json # @suaveplan/pwa 0.1.0, "browser" entry, subpath exports README.md # root readme (root-level only) tsconfig.lib.json vite.config.ts vitest.config.ts src/ index.ts # explicit named re-exports constants/ index.ts # DEFAULT_* + storage keys types/ common.ts # SwState, SwRegistrationOptions, ..., PushNotificationOptions common.md errors/ errors.ts # PWA_CODES, PwaError, metadata table errors.test.ts errors.md schemas/ schemas.ts # Zod schemas: SyncQueueEntrySchema, ManifestSchema, ... schemas.test.ts schemas.md observability/ observability.ts # createPwaObservability() observability.types.ts observability.test.ts observability.md service-worker/ service-worker.ts # ServiceWorkerManager service-worker.types.ts service-worker.test.ts service-worker.md update-controller/ update-controller.ts # cross-tab skipWaiting + reload coordination update-controller.test.ts update-controller.md sync-queue/ sync-queue.ts # SyncQueue (pluggable store) sync-queue.types.ts sync-queue.test.ts sync-queue.md memory-store.ts # in-memory store (default for tests) memory-store.test.ts memory-store.md local-storage-store.ts # legacy LS-backed store local-storage-store.test.ts local-storage-store.md indexeddb-store.ts # default production store indexeddb-store.test.ts indexeddb-store.md background-sync.ts # SyncManager.register() bridge + visibility fallback background-sync.test.ts background-sync.md version-migration.ts # forward-migrate v1 → vN entries version-migration.test.ts version-migration.md push-notifications/ push-notifications.ts # PushNotificationManager push-notifications.types.ts push-notifications.test.ts push-notifications.md vapid.ts # urlBase64ToUint8Array + ApplicationServerKey type vapid.test.ts vapid.md install-prompt/ install-prompt.ts # InstallPromptManager install-prompt.types.ts install-prompt.test.ts install-prompt.md caching-strategies/ caching-strategies.ts # cacheFirst / networkFirst / staleWhileRevalidate / cacheOnly / networkOnly caching-strategies.types.ts caching-strategies.test.ts caching-strategies.md router.ts # registerRoute() + matchRoute() router.test.ts router.md manifest/ manifest.ts # ManifestBuilder manifest.types.ts manifest.test.ts manifest.md ``` ## Injectable seams (no globals in `src/`) Every browser API the package consumes is reached via an injectable factory carrying the real-shaped contract from `lib.dom.d.ts`. Production wiring picks the seam up from `globalThis`; tests inject in-memory implementations with identical shapes. | Seam | Default | Test override | |---|---|---| | `ServiceWorkerContainer` | `navigator.serviceWorker` | `FakeServiceWorkerContainer` matching the lib types. | | `PushManager` factory | `(reg) => reg.pushManager` | `FakePushManager` | | `Notification` static | `globalThis.Notification` | `FakeNotificationStatic` | | `IDBFactory` | `globalThis.indexedDB` | `fake-indexeddb` (already on `happy-dom`) | | `BroadcastChannel` ctor | `globalThis.BroadcastChannel` | happy-dom's real ctor | | `WindowLike` (`addEventListener`, `removeEventListener`) | `window` | `FakeEventTarget` | | `Clock` | `Date.now` | `FakeClock` from `@suaveplan/testing` | | `Storage` (LocalStorage backend only) | `globalThis.localStorage` | `MemoryStorage` | Everything is constructed via the manager's options object; the manager NEVER reaches `globalThis` from inside `src/`. ## Caching-strategy router A SW author registers strategies on the `Router`: ```ts const router = new Router({ observability }); router.registerRoute({ method: "GET", pattern: /\/api\//, strategy: networkFirst({ ttl: 30_000 }) }); router.registerRoute({ method: "GET", pattern: /\.(?:png|jpg|webp|svg)$/, strategy: cacheFirst({ cacheName: "img" }) }); self.addEventListener("fetch", (e) => e.respondWith(router.handle(e.request))); ``` Each strategy is a pure function `(req, ctx) => Promise` with a typed context `{ cache: Cache, fetcher: typeof fetch, clock: Clock }`. No global access. ## Sync-queue pipeline 1. `enqueue(req)` → Zod-validates → store.put() → `observability.counters.enqueued.add(1)`. 2. `process()` → - read all → schema-validate (with `VersionMigration.upgrade(entry)`) → - per entry: `onProcess(entry)` (or default `fetch(...)`) → - success → remove + counter increment; - failure with backoff < maxRetries → retry-count++, persisted; - failure with backoff ≥ maxRetries → `onFailure(entry)` + remove + counter. 3. `BackgroundSyncBridge.register(tag)` → `SyncManager.register(tag)` when supported; else `document.addEventListener("visibilitychange", flushOnVisible)`. ## Version migration `SyncQueueEntry` carries `schemaVersion`. `VersionMigration` exports an ordered list of migrators `((entry) => entry)` keyed by from-version. On load, every persisted entry is upgraded to the current version (currently `2`) before being handed to consumers. ## Update-controller cross-tab coordination Every tab opens a `BroadcastChannel('@suaveplan/pwa/update')`. When `skipWaiting()` is invoked from one tab, the channel emits `{ type: "RELOAD" }`; receivers call `window.location.reload()`. This avoids the "first tab activated the new SW while the others are still serving the old one" problem.