Files
suaveplan/openspec/specs/pwa/spec.md
2026-05-27 23:29:53 +01:00

18 KiB

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<Entry[]>; save(entries: Entry[]): Promise<void> }. 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 Uint8Arrays 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/"