Initial Commit
This commit is contained in:
148
openspec/archive/2026-05-25-enterprise-pwa/design.md
Normal file
148
openspec/archive/2026-05-25-enterprise-pwa/design.md
Normal 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.
|
||||
87
openspec/archive/2026-05-25-enterprise-pwa/proposal.md
Normal file
87
openspec/archive/2026-05-25-enterprise-pwa/proposal.md
Normal 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.
|
||||
417
openspec/archive/2026-05-25-enterprise-pwa/specs/pwa/spec.md
Normal file
417
openspec/archive/2026-05-25-enterprise-pwa/specs/pwa/spec.md
Normal 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/"`
|
||||
46
openspec/archive/2026-05-25-enterprise-pwa/tasks.md
Normal file
46
openspec/archive/2026-05-25-enterprise-pwa/tasks.md
Normal 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).
|
||||
Reference in New Issue
Block a user