149 lines
5.9 KiB
Markdown
149 lines
5.9 KiB
Markdown
# 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.
|