Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
# Design: `@suaveplan/pwa`
> Non-normative companion to `./proposal.md` and `./specs/pwa/spec.md`.
## File layout
```text
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`:
```ts
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.

View File

@@ -0,0 +1,87 @@
# Proposal: Enterprise migration of `@suaveplan/pwa`
- **Status**: PROPOSED
- **Package**: `@suaveplan/pwa`
- **Tier**: browser
- **Category / plugin**: `foundation / data`
- **Source**: upstream `packages/browser/pwa` (1.0.4)
- **Design**: ./design.md
- **Spec delta**: ./specs/pwa/spec.md
## Summary
Migrate `@suaveplan/pwa` from `../suaveplan/packages/browser/pwa` (1.0.4) to genesis at
`packages/foundation/data/pwa/` as version `0.1.0`. The upstream package ships four
managers (`ServiceWorkerManager`, `SyncQueue`, `PushNotificationManager`,
`InstallPromptManager`) but relies heavily on `vi.stubGlobal` in tests (banned by
CLAUDE rule 2), stores the sync queue in `localStorage`, has no Zod boundaries, no
OTel, no typed errors, and no caching-strategy abstraction or manifest builder.
The genesis rewrite ships the full contract:
- **`ServiceWorkerManager`** — registration, activation, update polling, `skipWaiting`,
state-change listeners, injectable `ServiceWorkerContainer` seam.
- **`SyncQueue`** — pluggable persistence (`MemoryStore`, `IndexedDbStore`,
`LocalStorageStore`), Zod-validated entries, exponential-backoff retry,
`BackgroundSyncBridge` for `SyncManager.register()` with tab-visibility fallback.
- **`PushNotificationManager`** — permission + `PushManager` subscribe / unsubscribe,
VAPID `applicationServerKey` decoding, convenience notification dispatch through
the active registration.
- **`InstallPromptManager`** — `beforeinstallprompt` capture, programmatic `prompt()`,
`appinstalled` integration, dismiss persistence.
- **`CachingStrategies`** — `cacheFirst`, `networkFirst`, `staleWhileRevalidate`,
`networkOnly`, `cacheOnly` route-handlers + `registerRoute(pattern, strategy)`
router suitable for use inside a SW global scope.
- **`ManifestBuilder`** — fluent builder for the Web App Manifest (name, icons,
shortcuts, related apps, share target, display, theme, orientation, scope) with
Zod validation and `toBlob()` for inline registration.
- **`UpdateController`** — coordinates `skipWaiting` + `controllerchange` reload across
tabs via `BroadcastChannel`, exposes "update available" event.
- **`VersionMigration`** — schema-versioned queue migration so older payloads in
storage are upgraded on `SyncQueue` load.
- **`PwaObservability`** — OTel counters + histograms covering SW install / activate /
update, sync queue flush, push subscriptions, install-prompt acceptance.
- **`PwaError` + `PWA_CODES`** — typed errors with metadata for HTTP status,
severity, retryability, description.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `ServiceWorkerManager` (register / unregister / checkForUpdate / skipWaiting / state / onStateChange) | OK | OK | Plus injectable container seam, typed error on failure. |
| `SyncQueue` (enqueue / remove / getAll / size / clear / process) | OK | OK | Pluggable store; default switched to IndexedDB; localStorage still available as a `LocalStorageStore`. |
| `PushNotificationManager` (isSupported / getPermission / requestPermission / subscribe / unsubscribe / getSubscription) | OK | OK | Same surface; VAPID decoding extracted into a helper. |
| `InstallPromptManager` (listen / unlisten / prompt / dismiss / clearDismissed / state / onStateChange / reset) | OK | OK | Plus persisted dismissal across sessions. |
| Constants (`DEFAULT_*`, `SYNC_QUEUE_STORAGE_KEY`, `INSTALL_PROMPT_KEY`) | OK | OK | Preserved + extended (`DEFAULT_RETRY_BACKOFF_FACTOR`). |
| Subpath exports per module | OK | OK | Expanded per new modules. |
| Caching strategies (`cacheFirst`, `networkFirst`, `staleWhileRevalidate`, …) | none | OK | New. |
| Route router (`registerRoute`) | none | OK | New. |
| Web App Manifest builder | none | OK | New. |
| Update controller w/ cross-tab reload coordination | none | OK | New. |
| Background-sync bridge (`SyncManager.register()`) | none | OK | New, with visibility-change fallback. |
| Version migration on stored queue payloads | none | OK | New, schema bump from v1 → vN. |
| Zod validation on `SyncQueueEntry` | none | OK | New. |
| OTel counters + histograms | none | OK | New. |
| `PWA_CODES` + `PwaError` | none | OK | New 8-code registry. |
| `vi.stubGlobal` in tests | yes | none | Replaced with happy-dom + in-memory seams. |
| `// @ts-expect-error` in tests | 4 | 0 | Removed; edge cases modelled as typed branches. |
## Impact
### Affected dependents
No existing genesis package depends on `@suaveplan/pwa` today. Future consumers (the
suaveplan webapp shell + service-worker bundle) will pick up the new subpath exports.
### Risks / mitigations
- **IndexedDB on WSL2 + happy-dom** — happy-dom ships `fake-indexeddb`-compatible
IDB. We seam `IDBFactory` so unit tests run against a deterministic in-memory store
injected by the test, and only the IDB-store-specific integration tests touch
happy-dom's IDB.
- **`PushManager` / `Notification` absent in non-secure contexts** — every entry
point guards with feature detection and falls back to a typed `PWA.PUSH_UNSUPPORTED`
error; the test seam supplies a real-shaped `PushManager` stand-in.
- **BroadcastChannel polyfill** — happy-dom exposes a real `BroadcastChannel`, so the
`UpdateController` cross-tab coordination is exercised in unit tests against the
same primitive production uses.

View File

@@ -0,0 +1,417 @@
<!-- 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/"`

View File

@@ -0,0 +1,46 @@
# Tasks: enterprise-pwa
## Scaffolding
- [ ] Add `pwa` to `data:` plugin in `scripts/folder-reorg/assignment.yml`.
- [ ] Create `packages/foundation/data/pwa/` skeleton (`package.json`, `README.md`,
`tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`).
- [ ] Wire workspace + catalog deps: `@suaveplan/error`, `@suaveplan/opentelemetry`,
`@suaveplan/types`, `tslib`, `@opentelemetry/api`, `zod`.
## Implementation
- [ ] `errors/errors.ts` — `PWA_CODES`, `PwaError`, metadata table.
- [ ] `schemas/schemas.ts` — Zod schemas for sync-queue entry + Web App Manifest.
- [ ] `observability/observability.ts` + types.
- [ ] `service-worker/service-worker.ts` — `ServiceWorkerManager` with injectable container.
- [ ] `update-controller/update-controller.ts` — cross-tab reload via BroadcastChannel.
- [ ] `sync-queue/memory-store.ts` + `local-storage-store.ts` + `indexeddb-store.ts`.
- [ ] `sync-queue/version-migration.ts` — forward migrator registry.
- [ ] `sync-queue/sync-queue.ts` — pluggable queue.
- [ ] `sync-queue/background-sync.ts` — `SyncManager` bridge + visibility fallback.
- [ ] `push-notifications/vapid.ts` — URL-safe base64 decoder.
- [ ] `push-notifications/push-notifications.ts` — `PushNotificationManager`.
- [ ] `install-prompt/install-prompt.ts` — `InstallPromptManager`.
- [ ] `caching-strategies/caching-strategies.ts` — 5 strategies.
- [ ] `caching-strategies/router.ts` — `Router.registerRoute()` + `handle()`.
- [ ] `manifest/manifest.ts` — `ManifestBuilder`.
- [ ] `index.ts` — explicit named re-exports (rule 10).
## Documentation
- [ ] Sibling `.md` next to every `.ts` per CLAUDE rule (≥200 words, 6 sections).
- [ ] Root `README.md` with quick-start + module links.
## Gates
- [ ] `bunx turbo lint typecheck test build --filter=@suaveplan/pwa` green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/pwa` ≥ 100/100/100/100.
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` (refresh MANIFEST).
## Finalisation
- [ ] Fold `specs/pwa/spec.md` into `openspec/specs/pwa/spec.md`.
- [ ] Move change to `openspec/archive/2026-05-25-enterprise-pwa/`.
- [ ] Flip `STATUS.md` row from `–` to `✅` and bump counts.
- [ ] Commit on `feat/migrate-pwa` with Conventional Commit + Claude co-author footer (do not push).