# `pwa` capability **Package**: `@suaveplan/pwa` **Status**: SHIPPED at v0.1.0 (`2026-05-25-enterprise-pwa`) > Every requirement carries a `**Verified by:**` line; the verifying test name > matches exactly. ## ADDED Requirements ### Requirement: Stable error-code registry The package SHALL expose `PWA_CODES` via `defineCodes("PWA", [...])` with exactly these member codes: `SW_UNSUPPORTED`, `SW_REGISTRATION_FAILED`, `SW_UPDATE_FAILED`, `PUSH_UNSUPPORTED`, `PUSH_PERMISSION_DENIED`, `SYNC_QUEUE_FULL`, `SYNC_QUEUE_VALIDATION`, `MANIFEST_VALIDATION`. The package SHALL export a `PwaError extends SuaveplanError` class and a frozen `PWA_ERROR_METADATA` table mapping every code to `{ httpStatus, severity, retryable, description }`. #### Scenario: every code is reachable from a real call site - **WHEN** the consumer triggers each documented failure path (unsupported SW, registration rejection, update failure, push unsupported, push permission denied, queue overflow, invalid entry, invalid manifest) - **THEN** a `PwaError` with the matching `code` is raised and the metadata table contains an entry for it **Verified by:** `src/errors/errors.test.ts::"every code has metadata and is reachable from a documented call site"` --- ### Requirement: Injectable ServiceWorkerContainer seam `ServiceWorkerManager` SHALL accept an optional `container?: ServiceWorkerContainer` option. When omitted, the manager SHALL read `navigator.serviceWorker` lazily via an internal accessor — `src/` SHALL contain no direct `navigator.serviceWorker` access outside that accessor. Tests SHALL inject fake containers conforming to the `ServiceWorkerContainer` DOM interface to exercise every lifecycle path. #### Scenario: fake container drives every state transition - **WHEN** a test passes a fake container whose `register()` returns a registration with `installing` / `waiting` / `active` workers and emits `updatefound` / `statechange` events - **THEN** the manager transitions through `installing → installed → activated`, invokes `onUpdate` / `onSuccess` callbacks, and emits state-change events without touching `globalThis.navigator` **Verified by:** `src/service-worker/service-worker.test.ts::"transitions through every lifecycle state via the injected container"` --- ### Requirement: Service Worker registration + update polling `ServiceWorkerManager.register(options)` SHALL register a service worker at the given `scriptUrl` with the supplied `scope`, attach `updatefound` and `statechange` listeners, and — when `options.updateInterval > 0` — schedule a recurring `registration.update()` call via the injected `Timers` seam. `unregister()` SHALL clear the timer and tear down the registration. `checkForUpdate()` SHALL invoke `registration.update()` and return `true` on success, `false` on rejection. `skipWaiting()` SHALL `postMessage({ type: "SKIP_WAITING" })` to the waiting worker. #### Scenario: update timer fires and stops on unregister - **WHEN** `register({ updateInterval: 100 })` is called against a fake container with a fake timers seam - **THEN** the timers seam records a 100 ms interval call and `update()` is invoked on each tick; after `unregister()` no further `update()` calls are dispatched **Verified by:** `src/service-worker/service-worker.test.ts::"schedules update polling and stops on unregister"` --- ### Requirement: Typed error on registration failure When the container's `register()` rejects, `ServiceWorkerManager.register()` SHALL resolve a `SwRegistrationResult` whose `success` is `false` and `error.code` is `PWA.SW_REGISTRATION_FAILED`. The thrown rejection's `cause` SHALL be preserved on the returned error. #### Scenario: rejection becomes typed error - **WHEN** the container rejects with `new TypeError("script not reachable")` - **THEN** the returned `SwRegistrationResult` carries a `PwaError` with code `PWA.SW_REGISTRATION_FAILED` and `error.cause` equal to the original `TypeError` **Verified by:** `src/service-worker/service-worker.test.ts::"surfaces registration rejection as PwaError"` --- ### Requirement: Cross-tab update coordination `UpdateController` SHALL open a `BroadcastChannel` whose name defaults to `@suaveplan/pwa/update`, listen for `{ type: "RELOAD" }`, and reload via the injected `Window` seam when received. `signalReload()` SHALL post `{ type: "RELOAD" }` on the same channel. `close()` SHALL close the channel. #### Scenario: one tab signals, the others reload - **WHEN** tab A constructs an `UpdateController` with a fake `BroadcastChannel` factory and `Window` seam, then calls `signalReload()` - **THEN** tab B's `UpdateController` (constructed against the same fake channel hub) observes a message and invokes its fake `Window.location.reload()` **Verified by:** `src/update-controller/update-controller.test.ts::"broadcasts reload and triggers reload on receivers"` --- ### Requirement: Zod-validated sync queue entries The package SHALL export a `SyncQueueEntrySchema` Zod schema covering `id`, `url`, `method`, optional `headers`, optional `body`, `createdAt`, `retryCount`, `maxRetries`, optional `metadata`, and `schemaVersion`. `SyncQueue.enqueue()` SHALL validate the constructed entry through the schema before persistence; validation failures SHALL raise a `PwaError` with code `PWA.SYNC_QUEUE_VALIDATION`. #### Scenario: malformed metadata is rejected at the boundary - **WHEN** `enqueue()` receives metadata whose value graph contains an unsupported type (e.g. a `Function`) - **THEN** the call raises `PwaError` with code `PWA.SYNC_QUEUE_VALIDATION` and no entry is persisted **Verified by:** `src/sync-queue/sync-queue.test.ts::"raises SYNC_QUEUE_VALIDATION for malformed metadata"` --- ### Requirement: Pluggable sync-queue store `SyncQueue` SHALL accept a `store: SyncQueueStore` whose interface is `{ load(): Promise; save(entries: Entry[]): Promise }`. The package SHALL ship three implementations: `MemoryStore` (default for tests), `LocalStorageStore` (legacy), and `IndexedDbStore` (default for production). All three SHALL be exercised by the same conformance test harness. #### Scenario: every store implementation passes the conformance harness - **WHEN** `runStoreConformance(store)` is invoked against each shipped store - **THEN** every harness check (initial load empty, save round-trip, save overwrite, load after save, clear via save empty) passes **Verified by:** `src/sync-queue/memory-store.test.ts::"passes conformance harness"` AND `src/sync-queue/local-storage-store.test.ts::"passes conformance harness"` AND `src/sync-queue/indexeddb-store.test.ts::"passes conformance harness"` --- ### Requirement: SyncQueue retry + permanent-failure semantics `SyncQueue.process()` SHALL iterate persisted entries, invoking `onProcess(entry)` (or the default `fetch`-based processor) for each. On `true` the entry SHALL be removed and counted as processed. On `false` or thrown error, `retryCount` SHALL increment; when `retryCount >= maxRetries`, `onFailure(entry)` SHALL be invoked and the entry removed. The process step SHALL be a single-flight; concurrent invocations return `0` until the in-flight pass completes. #### Scenario: entry surviving below the retry cap is requeued - **WHEN** an entry with `maxRetries: 3` returns `false` from `onProcess` - **THEN** after one `process()` cycle the entry remains in the store with `retryCount === 1`, no `onFailure` invocation occurred, and the processed-counter did not advance **Verified by:** `src/sync-queue/sync-queue.test.ts::"requeues entries below maxRetries and invokes onFailure at the cap"` --- ### Requirement: SyncQueue overflow `SyncQueue.enqueue()` SHALL raise a `PwaError` with code `PWA.SYNC_QUEUE_FULL` when the queue already contains `maxSize` entries. #### Scenario: overflow raises typed error - **WHEN** a queue with `maxSize: 1` already holds one entry and `enqueue()` is called again - **THEN** a `PwaError` with code `PWA.SYNC_QUEUE_FULL` is raised and the queue size remains at 1 **Verified by:** `src/sync-queue/sync-queue.test.ts::"raises SYNC_QUEUE_FULL when at capacity"` --- ### Requirement: Background-sync bridge `BackgroundSyncBridge.register(tag)` SHALL call `registration.sync.register(tag)` when the injected registration exposes a `sync` `SyncManager`. When unsupported, the bridge SHALL attach a `visibilitychange` listener via the injected `WindowLike` that calls the supplied `flush()` callback whenever visibility becomes `"visible"`. `unregister()` SHALL remove the listener. #### Scenario: visibility fallback flushes the queue - **WHEN** a registration without `sync` is injected and `register("retry-pending", flush)` is called - **THEN** dispatching a `visibilitychange` event with `document.visibilityState = "visible"` invokes `flush()` exactly once per visibility transition to `visible` **Verified by:** `src/sync-queue/background-sync.test.ts::"falls back to visibilitychange when SyncManager is absent"` --- ### Requirement: Sync-queue schema version migration The package SHALL maintain a current `SYNC_QUEUE_SCHEMA_VERSION` constant and a `VersionMigration` registry mapping `fromVersion → (entry) => Entry`. On store load, every entry SHALL be migrated to the current version before validation. The migration SHALL be deterministic and idempotent. #### Scenario: v1 entries are upgraded on load - **WHEN** a store returns entries missing a `schemaVersion` field (legacy v1) - **THEN** the loaded entries carry the current `schemaVersion` and validate against `SyncQueueEntrySchema` **Verified by:** `src/sync-queue/version-migration.test.ts::"upgrades v1 entries to current schema version"` --- ### Requirement: Push subscription via injectable seam `PushNotificationManager` SHALL accept an injectable `ServiceWorkerContainer` and an injectable `NotificationStatic` (constructor + `permission` + `requestPermission`). `subscribe()` SHALL require `navigator.serviceWorker.ready`, decode the supplied VAPID `applicationServerKey` via the package's `urlBase64ToUint8Array()` helper, and return the subscription endpoint on success. #### Scenario: subscribe returns endpoint when permission is granted - **WHEN** a fake container yields a registration whose fake `PushManager.subscribe()` resolves to `{ endpoint: "https://push.example/abc" }` and the fake `NotificationStatic.requestPermission()` resolves to `"granted"` - **THEN** `subscribe({ applicationServerKey: validVapid })` resolves `{ subscribed: true, endpoint: "https://push.example/abc" }` and the bytes passed to `pushManager.subscribe` decode to the original VAPID key **Verified by:** `src/push-notifications/push-notifications.test.ts::"subscribes through the injected PushManager and returns endpoint"` --- ### Requirement: Push permission rejection is typed When `Notification.requestPermission()` resolves anything other than `"granted"`, `subscribe()` SHALL return `{ subscribed: false, error: PwaError(PUSH_PERMISSION_DENIED) }`. When `isSupported` is false (no `PushManager` or no `Notification`), `subscribe()` SHALL return `{ subscribed: false, error: PwaError(PUSH_UNSUPPORTED) }`. #### Scenario: denial yields PUSH_PERMISSION_DENIED - **WHEN** the fake `NotificationStatic.requestPermission()` resolves `"denied"` - **THEN** the returned `PushSubscriptionResult.error` is a `PwaError` with code `PWA.PUSH_PERMISSION_DENIED` **Verified by:** `src/push-notifications/push-notifications.test.ts::"reports PUSH_PERMISSION_DENIED on denial"` --- ### Requirement: VAPID key decoding `urlBase64ToUint8Array(key: string)` SHALL convert a URL-safe base64 VAPID public key into a `Uint8Array` of length 65, accepting both unpadded and padded inputs. Invalid base64 SHALL raise an `Error` (caller is expected to validate input shape elsewhere). #### Scenario: padded and unpadded keys decode identically - **WHEN** a 87-character URL-safe base64 string and the same value with `=` padding are decoded - **THEN** the two `Uint8Array`s are byte-identical and length 65 **Verified by:** `src/push-notifications/vapid.test.ts::"decodes padded and unpadded VAPID keys identically"` --- ### Requirement: Install-prompt capture + dismiss persistence `InstallPromptManager.listen()` SHALL attach `beforeinstallprompt` and `appinstalled` listeners via the injected `WindowLike`. `prompt()` SHALL invoke the captured event's `prompt()` and resolve to `accepted` or `dismissed`; `dismissed` SHALL persist the dismissal via the injected `Storage`. `clearDismissed()` SHALL clear the persisted flag. `state` SHALL transition `idle → available → accepted | dismissed`. #### Scenario: dismissal persists across sessions - **WHEN** the user dismisses via `prompt()` and a new `InstallPromptManager` is later constructed against the same `Storage` - **THEN** `wasDismissed` is `true` **Verified by:** `src/install-prompt/install-prompt.test.ts::"persists dismissal across instances"` --- ### Requirement: Caching strategies The package SHALL export `cacheFirst`, `networkFirst`, `staleWhileRevalidate`, `cacheOnly`, and `networkOnly` strategies. Each strategy SHALL accept a `StrategyContext = { cache: Cache; fetcher: typeof fetch; clock: Clock }` and an optional `ttlMs`. `cacheFirst` returns the cached response if present (and not expired), else fetches and caches. `networkFirst` fetches and caches; on network failure falls back to cache. `staleWhileRevalidate` returns cached immediately when present, refreshing in the background. `cacheOnly` returns the cached response or throws a 504 `Response`. `networkOnly` bypasses the cache entirely. #### Scenario: every strategy handles cached + uncached paths - **WHEN** the strategy is invoked with a request against (a) a cache containing the response and (b) an empty cache - **THEN** the returned response, the underlying `fetcher` call count, and the cache state after the call match the strategy's documented contract **Verified by:** `src/caching-strategies/caching-strategies.test.ts::"cacheFirst hits cache when present and falls through otherwise"` AND `src/caching-strategies/caching-strategies.test.ts::"networkFirst falls back to cache on network failure"` AND `src/caching-strategies/caching-strategies.test.ts::"staleWhileRevalidate refreshes in the background"` AND `src/caching-strategies/caching-strategies.test.ts::"cacheOnly returns 504 on miss"` AND `src/caching-strategies/caching-strategies.test.ts::"networkOnly never reads the cache"` --- ### Requirement: Route registration `Router.registerRoute({ method, pattern, strategy })` SHALL accept HTTP method, `RegExp | string | (Request) => boolean` pattern, and a strategy handler. `Router.handle(request)` SHALL find the first matching route and invoke its strategy with a context bound to the `Cache` whose name matches the route's `cacheName` (or the default cache). Unmatched requests SHALL fall through to `fetch(request)`. #### Scenario: method and pattern are both honored - **WHEN** a router has a `GET /api/*` route only and a `POST /api/x` request is handled - **THEN** the strategy is NOT invoked and the request is fetched via the default fetcher **Verified by:** `src/caching-strategies/router.test.ts::"matches on method and pattern; falls through otherwise"` --- ### Requirement: Web App Manifest builder `ManifestBuilder` SHALL provide fluent setters for `name`, `shortName`, `description`, `themeColor`, `backgroundColor`, `display`, `orientation`, `scope`, `startUrl`, `lang`, `dir`, plus `addIcon`, `addShortcut`, `addRelatedApplication`, `setShareTarget`. `build()` SHALL Zod-validate the result against `WebAppManifestSchema`; validation failures SHALL raise `PwaError` with code `PWA.MANIFEST_VALIDATION`. `toBlob()` SHALL serialise to a JSON `Blob` of MIME type `application/manifest+json`. #### Scenario: missing required name fails validation - **WHEN** `build()` is called on a builder that never received `setName(...)` - **THEN** a `PwaError` with code `PWA.MANIFEST_VALIDATION` is raised **Verified by:** `src/manifest/manifest.test.ts::"raises MANIFEST_VALIDATION when required fields are missing"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL expose `createPwaObservability(opts?)` returning `{ meter, tracer, counters, histograms }`. Counters SHALL include `pwa_sw_installed_total`, `pwa_sw_activated_total`, `pwa_sw_updated_total`, `pwa_sync_enqueued_total`, `pwa_sync_processed_total`, `pwa_sync_failed_total`, `pwa_push_subscribed_total`, `pwa_install_prompt_total`. Histograms SHALL include `pwa_sync_flush_ms` and `pwa_cache_strategy_ms`. Every public manager SHALL accept the observability bundle and emit on its documented signals. #### Scenario: SyncQueue.process records flush duration - **WHEN** a queue with one entry is processed against a recording meter - **THEN** `pwa_sync_processed_total` increments by 1, `pwa_sync_flush_ms` records a single non-negative observation, and no `pwa_sync_failed_total` increment is emitted **Verified by:** `src/observability/observability.test.ts::"emits on every documented counter and histogram"` --- ### Requirement: Subpath exports per module `package.json` SHALL declare subpath exports for `.`, `./errors`, `./schemas`, `./service-worker`, `./update-controller`, `./sync-queue`, `./push-notifications`, `./install-prompt`, `./caching-strategies`, `./manifest`, `./observability`. All exports SHALL ship `{ types, default }` for ESM-only consumption. #### Scenario: every documented subpath resolves and re-exports the module surface - **WHEN** each subpath is imported in a typecheck-only test file - **THEN** the named exports from the module are reachable through the subpath **Verified by:** `src/index.test.ts::"every documented subpath resolves to its module"` --- ### Requirement: Tier purity — browser only `src/` SHALL not import any Node-only globals (`process`, `Buffer`, `node:*`). `package.json` SHALL declare `"browser": "./dist/index.js"`. Tests run under `happy-dom`. #### Scenario: src/ contains no Node-only imports - **WHEN** the package's source tree is scanned for `node:` specifiers and `process.` / `Buffer.` identifiers - **THEN** zero matches are found **Verified by:** `src/index.test.ts::"src/ contains no Node-only globals"` --- ### Requirement: No banned patterns `src/` SHALL contain zero matches for `vi.stubGlobal`, `vi.mock`, `// @ts-ignore`, `// @ts-expect-error`, `TODO`, `FIXME`, `.skip`, `.only`, `console.log`, `any` bare, or `// biome-ignore`. Tests SHALL import the runner exclusively from `@suaveplan/testing/runner`. #### Scenario: lint sweep finds zero banned patterns - **WHEN** the package source tree is grep-scanned for each banned pattern - **THEN** zero matches are found **Verified by:** `src/index.test.ts::"no banned patterns in src/"`