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

418 lines
18 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: pwa (pwa)
**Package**: `@suaveplan/pwa`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## 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 `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/"`