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
PwaErrorwith the matchingcodeis 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 withinstalling/waiting/activeworkers and emitsupdatefound/statechangeevents - THEN the manager transitions through
installing → installed → activated, invokesonUpdate/onSuccesscallbacks, and emits state-change events without touchingglobalThis.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; afterunregister()no furtherupdate()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
SwRegistrationResultcarries aPwaErrorwith codePWA.SW_REGISTRATION_FAILEDanderror.causeequal to the originalTypeError
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
UpdateControllerwith a fakeBroadcastChannelfactory andWindowseam, then callssignalReload() - THEN tab B's
UpdateController(constructed against the same fake channel hub) observes a message and invokes its fakeWindow.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. aFunction) - THEN the call raises
PwaErrorwith codePWA.SYNC_QUEUE_VALIDATIONand 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: 3returnsfalsefromonProcess - THEN after one
process()cycle the entry remains in the store withretryCount === 1, noonFailureinvocation 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: 1already holds one entry andenqueue()is called again - THEN a
PwaErrorwith codePWA.SYNC_QUEUE_FULLis 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
syncis injected andregister("retry-pending", flush)is called - THEN dispatching a
visibilitychangeevent withdocument.visibilityState = "visible"invokesflush()exactly once per visibility transition tovisible
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
schemaVersionfield (legacy v1) - THEN the loaded entries carry the current
schemaVersionand validate againstSyncQueueEntrySchema
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 fakeNotificationStatic.requestPermission()resolves to"granted" - THEN
subscribe({ applicationServerKey: validVapid })resolves{ subscribed: true, endpoint: "https://push.example/abc" }and the bytes passed topushManager.subscribedecode 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.erroris aPwaErrorwith codePWA.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 newInstallPromptManageris later constructed against the sameStorage - THEN
wasDismissedistrue
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
fetchercall 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 aPOST /api/xrequest 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 receivedsetName(...) - THEN a
PwaErrorwith codePWA.MANIFEST_VALIDATIONis 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_totalincrements by 1,pwa_sync_flush_msrecords a single non-negative observation, and nopwa_sync_failed_totalincrement 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 andprocess./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/"