5.9 KiB
Design: @suaveplan/pwa
Non-normative companion to
./proposal.mdand./specs/pwa/spec.md.
File layout
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:
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<Response> with a typed
context { cache: Cache, fetcher: typeof fetch, clock: Clock }. No global access.
Sync-queue pipeline
enqueue(req)→ Zod-validates → store.put() →observability.counters.enqueued.add(1).process()→- read all → schema-validate (with
VersionMigration.upgrade(entry)) → - per entry:
onProcess(entry)(or defaultfetch(...)) → - success → remove + counter increment;
- failure with backoff < maxRetries → retry-count++, persisted;
- failure with backoff ≥ maxRetries →
onFailure(entry)+ remove + counter.
- read all → schema-validate (with
BackgroundSyncBridge.register(tag)→SyncManager.register(tag)when supported; elsedocument.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.