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.