Files
2026-05-27 23:29:53 +01:00

5.9 KiB

Design: @suaveplan/pwa

Non-normative companion to ./proposal.md and ./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

  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.