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.
|
||||
Reference in New Issue
Block a user