first commit
This commit is contained in:
138
docs/foundation/data/pwa/README.md
Normal file
138
docs/foundation/data/pwa/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# @suaveplan/pwa - Enterprise browser-tier PWA primitives: ServiceWorkerManager + UpdateController, pluggable SyncQueue (memory / localStorage / IndexedDB stores) with Zod-validated entries + version migration + Background Sync bridge, PushNotificationManager + VAPID decoder, InstallPromptManager with persisted dismissal, caching-strategy DSL (cacheFirst / networkFirst / staleWhileRevalidate / cacheOnly / networkOnly) + Router, Web App Manifest builder, OTel counters + histograms, typed PWA.* error registry.
|
||||
|
||||
Enterprise browser-tier PWA primitives for Suaveplan applications.
|
||||
|
||||
- **Tier**: browser
|
||||
- **Category / plugin**: `foundation / data`
|
||||
- **Version**: 0.1.0
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [caching-strategies](./src/caching-strategies/caching-strategies.md) — Five pure async strategy functions (`cacheFirst`, `networkFirst`, `staleWhileRevalidate`, `cacheOnly`, `networkOnly`) t…
|
||||
- [caching-strategies](./src/caching-strategies/caching-strategies.md) — Five pure async strategy functions (`cacheFirst`, `networkFirst`, `staleWhileRevalidate`, `cacheOnly`, `networkOnly`) t…
|
||||
- [router](./src/caching-strategies/router.md) — `Router` evaluates incoming `Request`s against a list of registered routes in order and dispatches the first match to i…
|
||||
- [errors](./src/errors/errors.md) — Defines the `PWA_CODES` stable error-code registry, the `PwaError` base class, and the frozen `PWA_ERROR_METADATA` tabl…
|
||||
- [errors](./src/errors/errors.md) — Defines the `PWA_CODES` stable error-code registry, the `PwaError` base class, and the frozen `PWA_ERROR_METADATA` tabl…
|
||||
- [install-prompt](./src/install-prompt/install-prompt.md) — `InstallPromptManager` captures the browser-fired `beforeinstallprompt` event and exposes it as a typed state machine t…
|
||||
- [install-prompt](./src/install-prompt/install-prompt.md) — `InstallPromptManager` captures the browser-fired `beforeinstallprompt` event and exposes it as a typed state machine t…
|
||||
- [manifest](./src/manifest/manifest.md) — `ManifestBuilder` provides a fluent, type-safe builder for the W3C Web App Manifest. Every setter returns `this` so con…
|
||||
- [manifest](./src/manifest/manifest.md) — `ManifestBuilder` provides a fluent, type-safe builder for the W3C Web App Manifest. Every setter returns `this` so con…
|
||||
- [observability](./src/observability/observability.md) — Single factory function that builds the `PwaObservability` bundle threaded through every public manager in `@suaveplan/…
|
||||
- [observability](./src/observability/observability.md) — Single factory function that builds the `PwaObservability` bundle threaded through every public manager in `@suaveplan/…
|
||||
- [push-notifications](./src/push-notifications/push-notifications.md) — `PushNotificationManager` wraps the W3C Push API and `Notification` static interface behind injectable seams. The manag…
|
||||
- [push-notifications](./src/push-notifications/push-notifications.md) — `PushNotificationManager` wraps the W3C Push API and `Notification` static interface behind injectable seams. The manag…
|
||||
- [vapid](./src/push-notifications/vapid.md) — Single-helper module that decodes a URL-safe base64 VAPID application server key into the raw `Uint8Array` the Push API…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding the package's two persisted boundaries: the sync-queue entry and the Web App Manifest. Every value…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding the package's two persisted boundaries: the sync-queue entry and the Web App Manifest. Every value…
|
||||
- [service-worker](./src/service-worker/service-worker.md) — `ServiceWorkerManager` wraps the browser `ServiceWorkerContainer` API behind an injectable seam so production code reac…
|
||||
- [service-worker](./src/service-worker/service-worker.md) — `ServiceWorkerManager` wraps the browser `ServiceWorkerContainer` API behind an injectable seam so production code reac…
|
||||
- [sync-queue](./src/sync-queue/sync-queue.md) — Offline-first request queue that persists requests while the device is offline and replays them when connectivity retur…
|
||||
- [background-sync](./src/sync-queue/background-sync.md) — Bridges the standard W3C Background Sync API (`SyncManager.register(tag)`) into the `@suaveplan/pwa` package. Where the…
|
||||
- [indexeddb-store](./src/sync-queue/indexeddb-store.md) — Default production `SyncQueueStore` implementation, backed by IndexedDB. IDB is the only browser-native storage that su…
|
||||
- [local-storage-store](./src/sync-queue/local-storage-store.md) — Backwards-compatible `SyncQueueStore` backed by the synchronous `Storage` interface (`localStorage` in production). Ret…
|
||||
- [memory-store](./src/sync-queue/memory-store.md) — Fastest possible `SyncQueueStore` implementation, backed by a private array. Used as the default in unit tests and as a…
|
||||
- [sync-queue](./src/sync-queue/sync-queue.md) — Offline-first request queue that persists requests while the device is offline and replays them when connectivity retur…
|
||||
- [version-migration](./src/sync-queue/version-migration.md) — Forward-migrates raw `SyncQueueEntry` records loaded from persistent storage (IndexedDB / localStorage) to the current…
|
||||
- [types](./src/types/common.md) — Central type definitions shared across the `@suaveplan/pwa` package. The module owns every cross-module structural cont…
|
||||
- [common](./src/types/common.md) — Central type definitions shared across the `@suaveplan/pwa` package. The module owns every cross-module structural cont…
|
||||
- [update-controller](./src/update-controller/update-controller.md) — `UpdateController` coordinates a synchronised page reload across every tab the application is open in when a new servic…
|
||||
- [update-controller](./src/update-controller/update-controller.md) — `UpdateController` coordinates a synchronised page reload across every tab the application is open in when a new servic…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Overview
|
||||
|
||||
`@suaveplan/pwa` ships the full set of progressive-web-app primitives every
|
||||
Suaveplan browser app needs:
|
||||
|
||||
- `ServiceWorkerManager` + `UpdateController` — registration, update polling,
|
||||
`skipWaiting`, and cross-tab reload coordination via `BroadcastChannel`.
|
||||
- `SyncQueue` — pluggable offline-first request queue with three shipped
|
||||
store implementations (`MemoryStore`, `LocalStorageStore`, `IndexedDbStore`),
|
||||
Zod-validated entries, exponential-backoff retry, version migration, and a
|
||||
`BackgroundSyncBridge` for `SyncManager` + `visibilitychange` fallback.
|
||||
- `PushNotificationManager` — permission flow, VAPID-based subscribe /
|
||||
unsubscribe through an injectable `ServiceWorkerContainer`.
|
||||
- `InstallPromptManager` — `beforeinstallprompt` capture with persisted
|
||||
dismissal across sessions.
|
||||
- Caching strategies — `cacheFirst`, `networkFirst`, `staleWhileRevalidate`,
|
||||
`cacheOnly`, `networkOnly` + a route `Router`.
|
||||
- `ManifestBuilder` — fluent, Zod-validated Web App Manifest builder.
|
||||
- Typed `PWA_CODES` error registry + `PwaError`.
|
||||
- OTel counters + histograms wired into every manager.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
ServiceWorkerManager,
|
||||
UpdateController,
|
||||
SyncQueue,
|
||||
IndexedDbStore,
|
||||
PushNotificationManager,
|
||||
InstallPromptManager,
|
||||
Router,
|
||||
cacheFirst,
|
||||
networkFirst,
|
||||
ManifestBuilder,
|
||||
} from "@suaveplan/pwa";
|
||||
|
||||
const swm = new ServiceWorkerManager();
|
||||
const updates = new UpdateController();
|
||||
const queue = new SyncQueue({ store: new IndexedDbStore() });
|
||||
const push = new PushNotificationManager();
|
||||
const ipm = new InstallPromptManager();
|
||||
|
||||
await swm.register({ scriptUrl: "/sw.js" });
|
||||
swm.onStateChange((state) => {
|
||||
if (state === "activated") updates.signalReload();
|
||||
});
|
||||
|
||||
ipm.listen();
|
||||
const result = await push.subscribe({
|
||||
applicationServerKey: process.env.VAPID_PUBLIC_KEY,
|
||||
});
|
||||
|
||||
const manifest = new ManifestBuilder()
|
||||
.setName("Suaveplan")
|
||||
.addIcon({ src: "/icon-512.png", sizes: "512x512" })
|
||||
.build();
|
||||
```
|
||||
|
||||
Inside the SW:
|
||||
|
||||
```ts
|
||||
import { Router, cacheFirst, networkFirst } from "@suaveplan/pwa";
|
||||
|
||||
const router = new Router({ cacheName: "v1" });
|
||||
router.registerRoute({ pattern: /\.(png|jpg|webp)$/, strategy: cacheFirst() });
|
||||
router.registerRoute({ pattern: /\/api\//, strategy: networkFirst() });
|
||||
self.addEventListener("fetch", (e) => e.respondWith(router.handle(e.request)));
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Module |
|
||||
| --- | --- |
|
||||
| `.` | barrel — every public symbol |
|
||||
| `./errors` | `PWA_CODES`, `PwaError`, metadata table |
|
||||
| `./schemas` | Zod schemas for sync-queue entry + Web App Manifest |
|
||||
| `./observability` | `createPwaObservability` factory + types |
|
||||
| `./service-worker` | `ServiceWorkerManager` |
|
||||
| `./update-controller` | `UpdateController` |
|
||||
| `./sync-queue` | `SyncQueue`, stores, migrator, background-sync bridge |
|
||||
| `./push-notifications` | `PushNotificationManager` + `urlBase64ToUint8Array` |
|
||||
| `./install-prompt` | `InstallPromptManager` |
|
||||
| `./caching-strategies` | 5 strategies + `Router` |
|
||||
| `./manifest` | `ManifestBuilder` |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test build --filter=@suaveplan/pwa
|
||||
bunx turbo test:coverage --filter=@suaveplan/pwa
|
||||
```
|
||||
|
||||
Targets `happy-dom` for DOM stubs and `fake-indexeddb` for `IDBFactory` in
|
||||
the `IndexedDbStore` conformance test.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# `caching-strategies/caching-strategies.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Five pure async strategy functions (`cacheFirst`, `networkFirst`,
|
||||
`staleWhileRevalidate`, `cacheOnly`, `networkOnly`) that compose with a
|
||||
service worker's `fetch` event handler to provide the canonical caching
|
||||
patterns every PWA needs. Each strategy is a pure function from
|
||||
`(Request, StrategyContext) → Promise<Response>` so callers can mount them
|
||||
on any router and tests can drive them deterministically against a real
|
||||
in-memory `Cache` plus an injected `fetch` mock function (not `vi.mock` — a
|
||||
plain function the test owns).
|
||||
|
||||
## Features
|
||||
|
||||
- 5 strategies covering the canonical Workbox-equivalents.
|
||||
- TTL-aware via a header stamp (`x-suaveplan-pwa-cached-at`) so cached
|
||||
responses can be aged out independently of native HTTP `Cache-Control`.
|
||||
- Injectable `fetcher` so cache strategies are testable without
|
||||
`globalThis.fetch`.
|
||||
- Stale-while-revalidate fires the revalidation as a true async task; the
|
||||
cache response is returned synchronously to the caller.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { cacheFirst, Router } from "@suaveplan/pwa";
|
||||
|
||||
const router = new Router({
|
||||
cache: await caches.open("v1"),
|
||||
fetcher: fetch,
|
||||
clock: { now: () => Date.now() },
|
||||
});
|
||||
router.registerRoute({
|
||||
method: "GET",
|
||||
pattern: /\.(png|jpg|webp)$/,
|
||||
strategy: cacheFirst({ ttlMs: 86_400_000 }),
|
||||
});
|
||||
self.addEventListener("fetch", (event) => event.respondWith(router.handle(event.request)));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose a stale-while-revalidate path with a 30s TTL:
|
||||
|
||||
```ts
|
||||
import { staleWhileRevalidate } from "@suaveplan/pwa";
|
||||
|
||||
const strategy = staleWhileRevalidate({ ttlMs: 30_000 });
|
||||
const response = await strategy(request, { cache, fetcher, clock });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cacheFirst(options?: StrategyOptions): Strategy`.
|
||||
- `networkFirst(options?: StrategyOptions): Strategy`.
|
||||
- `staleWhileRevalidate(options?: StrategyOptions): Strategy`.
|
||||
- `cacheOnly(options?: StrategyOptions): Strategy`.
|
||||
- `networkOnly(options?: StrategyOptions): Strategy`.
|
||||
- `StrategyContext` — `{ cache, fetcher, clock }`.
|
||||
- `Strategy` — `(req, ctx) => Promise<Response>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The TTL stamp is stored on a custom response header at cache-write time so
|
||||
revalidation can compare the cached entry's age against the current clock
|
||||
without rewriting it. The header is stripped (not visible to consumers) when
|
||||
the response is delivered via `cache.match()` because the platform `Cache`
|
||||
storage preserves headers exactly. Strategies that need to write back use
|
||||
`cachePut()`, which clones the response, stamps the timestamp, and stores
|
||||
the stamped clone — the original response retains its untouched body for the
|
||||
caller. `networkFirst` returns a 504 stub on hard offline + cache miss (or
|
||||
stale-only) rather than throwing, so the router can serve it directly to the
|
||||
browser without further error handling.
|
||||
87
docs/foundation/data/pwa/src/caching-strategies/router.md
Normal file
87
docs/foundation/data/pwa/src/caching-strategies/router.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# `caching-strategies/router.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`Router` evaluates incoming `Request`s against a list of registered routes
|
||||
in order and dispatches the first match to its strategy. The router is the
|
||||
glue between the SW `fetch` event and the package's caching strategies. It
|
||||
owns the default `Cache` and `fetcher` references so individual strategies
|
||||
stay free of global access — strategies receive their `StrategyContext`
|
||||
through the router and need not touch `caches`, `fetch`, or `Date.now`.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTP-method filtering (`GET`, `POST`, `PUT`, …) with case-insensitive
|
||||
comparison.
|
||||
- Three pattern types: `RegExp`, substring `string`, and predicate function
|
||||
(`(Request) => boolean`).
|
||||
- Per-route cache-name override so different routes can write to isolated
|
||||
caches without re-instantiating the router.
|
||||
- Fall-through to `fetcher(request)` for unmatched requests — the router is
|
||||
always safe to `respondWith()` because every request gets a response.
|
||||
- OTel `pwa_cache_strategy_ms` histogram recording strategy latency.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
Router,
|
||||
cacheFirst,
|
||||
networkFirst,
|
||||
staleWhileRevalidate,
|
||||
} from "@suaveplan/pwa";
|
||||
|
||||
const router = new Router({ cacheName: "v1" });
|
||||
router.registerRoute({
|
||||
pattern: /\.(png|jpg|webp)$/,
|
||||
strategy: cacheFirst({ ttlMs: 86_400_000 }),
|
||||
});
|
||||
router.registerRoute({
|
||||
pattern: /\/api\/static\//,
|
||||
strategy: staleWhileRevalidate(),
|
||||
});
|
||||
router.registerRoute({
|
||||
method: "GET",
|
||||
pattern: /\/api\//,
|
||||
strategy: networkFirst({ ttlMs: 30_000 }),
|
||||
});
|
||||
self.addEventListener("fetch", (event) =>
|
||||
event.respondWith(router.handle(event.request))
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Per-route cache + predicate matching:
|
||||
|
||||
```ts
|
||||
import { Router, cacheOnly } from "@suaveplan/pwa";
|
||||
|
||||
const router = new Router();
|
||||
router.registerRoute({
|
||||
method: "GET",
|
||||
pattern: (req) => req.url.startsWith("/offline-fallback/"),
|
||||
strategy: cacheOnly(),
|
||||
cacheName: "offline-fallbacks",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Router(options?: RouterOptions)`.
|
||||
- `registerRoute(route: Route): void`.
|
||||
- `handle(request: Request): Promise<Response>`.
|
||||
- `size: number`.
|
||||
- `Route` — `{ method?, pattern, strategy, cacheName? }`.
|
||||
- `RouterOptions` — `{ cacheStorage?, cacheName?, fetcher?, clock?, observability? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The router opens caches lazily via `cacheStorage.open()` on every `handle()`
|
||||
call. The platform `CacheStorage.open()` is idempotent and returns the same
|
||||
underlying `Cache` instance for the same name, so the per-call open is a
|
||||
trivial pointer lookup rather than a heavy initialisation. The fall-through
|
||||
path bypasses both the cache open and the strategy invocation entirely, so
|
||||
unmatched requests pay no per-request cost beyond the linear route scan.
|
||||
For applications with hundreds of routes, the scan can be optimised by the
|
||||
caller via predicate matchers that group routes by URL prefix.
|
||||
74
docs/foundation/data/pwa/src/errors/errors.md
Normal file
74
docs/foundation/data/pwa/src/errors/errors.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `errors/errors.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `PWA_CODES` stable error-code registry, the `PwaError` base class, and
|
||||
the frozen `PWA_ERROR_METADATA` table mapping every code to its HTTP status,
|
||||
severity, retryability, and human-readable description. Every error thrown or
|
||||
reported by `@suaveplan/pwa` carries one of these codes so retry strategies,
|
||||
logger pipelines, and observability sinks can classify failures without
|
||||
string-matching on the `Error.message`. Codes are namespaced under `PWA.*`.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable codes covering every documented failure path (service-worker
|
||||
unsupported / registration / update, push unsupported / permission, sync-queue
|
||||
full / validation, manifest validation).
|
||||
- `PwaError extends SuaveplanError` so the project's logger and wire serialiser
|
||||
treat the error identically to every other domain error.
|
||||
- Frozen `PWA_ERROR_METADATA` table with `{ httpStatus, severity, retryable,
|
||||
description }` per code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PWA_CODES, PwaError } from "@suaveplan/pwa";
|
||||
|
||||
throw new PwaError(
|
||||
PWA_CODES.SW_REGISTRATION_FAILED,
|
||||
"Service worker registration failed for /sw.js"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inspect metadata for retry decisions:
|
||||
|
||||
```ts
|
||||
import { PWA_ERROR_METADATA, PwaError, PWA_CODES } from "@suaveplan/pwa";
|
||||
|
||||
function shouldRetry(err: unknown): boolean {
|
||||
if (!(err instanceof PwaError)) return false;
|
||||
return PWA_ERROR_METADATA[err.code].retryable;
|
||||
}
|
||||
|
||||
const err = new PwaError(
|
||||
PWA_CODES.SW_UPDATE_FAILED,
|
||||
"ServiceWorkerRegistration.update rejected",
|
||||
{ cause: new Error("Network down") }
|
||||
);
|
||||
console.assert(shouldRetry(err));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PWA_CODES` — frozen record of `{ [Reason]: "PWA.<Reason>" }`.
|
||||
- `PwaErrorCode` — union of every documented code.
|
||||
- `PwaCodeMetadata` — `{ httpStatus, severity, retryable, description }`.
|
||||
- `PWA_ERROR_METADATA` — frozen mapping of every `PwaErrorCode` to its metadata.
|
||||
- `PwaError` — base error class, subclass of `SuaveplanError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PWA_ERROR_METADATA` is constructed with `Object.freeze` so consumers cannot mutate
|
||||
classifications at runtime. The class name is set to `PwaError` in the constructor
|
||||
so `error.name` matches the export name across every JS engine (including those
|
||||
that minimise class names). Codes are defined via `defineCodes("PWA", [...])`
|
||||
from `@suaveplan/error` to keep the namespace prefix in one place and to type the
|
||||
returned codes as template-literal strings (`"PWA.SW_UNSUPPORTED"`, etc.). This
|
||||
allows literal-typed unions in `PwaError<TCode>` without the implementation
|
||||
needing to thread the namespace string through every callsite.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `install-prompt/install-prompt.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InstallPromptManager` captures the browser-fired `beforeinstallprompt` event
|
||||
and exposes it as a typed state machine the application can drive
|
||||
programmatically. The manager also handles `appinstalled` for hot
|
||||
installations, persists dismissal across browser sessions (so dismissed users
|
||||
are not re-prompted on every visit), and emits OTel counters partitioned by
|
||||
outcome (`accepted` / `dismissed`).
|
||||
|
||||
## Features
|
||||
|
||||
- State machine: `idle → available → (accepted | dismissed)`.
|
||||
- `prompt()` invokes the captured deferred event and awaits the user's
|
||||
choice.
|
||||
- `dismiss()` clears the captured event without showing it, persisting the
|
||||
decision so future installs require an explicit `clearDismissed()`.
|
||||
- Injectable `WindowLike` + `StorageLike` seams so tests run without
|
||||
`globalThis` patching.
|
||||
- OTel `pwa_install_prompt_total` counter with `outcome` attribute.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InstallPromptManager } from "@suaveplan/pwa";
|
||||
|
||||
const ipm = new InstallPromptManager();
|
||||
ipm.listen();
|
||||
ipm.onStateChange((s) => {
|
||||
if (s === "available") showInstallBanner();
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject window + storage seams and drive every transition:
|
||||
|
||||
```ts
|
||||
import { InstallPromptManager } from "@suaveplan/pwa";
|
||||
|
||||
const window = createFakeWindow();
|
||||
const storage = createMemoryStorage();
|
||||
const ipm = new InstallPromptManager({ window, storage });
|
||||
ipm.listen();
|
||||
window.dispatchBeforeInstallPrompt();
|
||||
expect(ipm.isAvailable).toBe(true);
|
||||
await ipm.prompt();
|
||||
expect(ipm.state).toBe("accepted");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InstallPromptManager(options?: InstallPromptManagerOptions)`.
|
||||
- `state: InstallPromptState` (readonly).
|
||||
- `isAvailable: boolean`.
|
||||
- `wasDismissed: boolean`.
|
||||
- `listen(): void`.
|
||||
- `unlisten(): void`.
|
||||
- `prompt(): Promise<InstallPromptState>`.
|
||||
- `dismiss(): void`.
|
||||
- `clearDismissed(): void`.
|
||||
- `onStateChange(listener): () => void`.
|
||||
- `reset(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `WindowLike` seam exposes only `addEventListener` / `removeEventListener`
|
||||
so tests can pass a tiny `EventTarget` subclass without faking the full
|
||||
`Window` interface. The dismissal-persisting writes use a try/catch because
|
||||
`StorageLike` implementations can throw (`QuotaExceededError` under heavy
|
||||
quotas) and the install-prompt should remain functional even when persistence
|
||||
fails — at worst the user will be re-prompted next session. The manager
|
||||
explicitly does NOT trigger any platform-native UI itself: `prompt()` defers
|
||||
entirely to the browser's deferred event, and `dismiss()` only updates the
|
||||
internal state machine + storage.
|
||||
91
docs/foundation/data/pwa/src/manifest/manifest.md
Normal file
91
docs/foundation/data/pwa/src/manifest/manifest.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `manifest/manifest.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ManifestBuilder` provides a fluent, type-safe builder for the W3C Web App
|
||||
Manifest. Every setter returns `this` so configuration chains naturally;
|
||||
`build()` validates the accumulated state through `WebAppManifestSchema` and
|
||||
raises a typed `PwaError(MANIFEST_VALIDATION)` on failure; `toBlob()`
|
||||
serialises the validated manifest to a Blob with the correct MIME type
|
||||
(`application/manifest+json`) so it can be served from a Service Worker or
|
||||
inlined via a Blob URL.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent setter API for every standard manifest field (`name`, `short_name`,
|
||||
`description`, `start_url`, `scope`, `display`, `orientation`,
|
||||
`background_color`, `theme_color`, `lang`, `dir`).
|
||||
- Repeated-field setters: `addIcon`, `addShortcut`, `addRelatedApplication`.
|
||||
- `setShareTarget` for the W3C Web Share Target specification.
|
||||
- `setPreferRelatedApplications` for the native-app preference flag.
|
||||
- Zod-backed validation via `WebAppManifestSchema`.
|
||||
- `toBlob()` for direct delivery from a Service Worker `fetch` handler.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ManifestBuilder } from "@suaveplan/pwa";
|
||||
|
||||
const manifest = new ManifestBuilder()
|
||||
.setName("Suaveplan")
|
||||
.setShortName("SP")
|
||||
.setStartUrl("/")
|
||||
.setDisplay("standalone")
|
||||
.setThemeColor("#0f172a")
|
||||
.addIcon({ src: "/icon-192.png", sizes: "192x192", type: "image/png" })
|
||||
.addIcon({ src: "/icon-512.png", sizes: "512x512", type: "image/png" })
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Serve the manifest from a Service Worker:
|
||||
|
||||
```ts
|
||||
import { ManifestBuilder } from "@suaveplan/pwa";
|
||||
|
||||
const manifestBlob = new ManifestBuilder()
|
||||
.setName("Suaveplan")
|
||||
.addIcon({ src: "/icon.png", sizes: "512x512" })
|
||||
.addShortcut({
|
||||
name: "New post",
|
||||
url: "/posts/new",
|
||||
icons: [{ src: "/icon.png", sizes: "48x48" }],
|
||||
})
|
||||
.setShareTarget({
|
||||
action: "/share",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: { title: "title", text: "text" },
|
||||
})
|
||||
.toBlob();
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.url.endsWith("/manifest.webmanifest")) {
|
||||
event.respondWith(new Response(manifestBlob, { status: 200 }));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ManifestBuilder()` — no constructor args; configure via setters.
|
||||
- 14 setters returning `this`.
|
||||
- 3 add-* methods for icons / shortcuts / related apps.
|
||||
- `build(): WebAppManifest`.
|
||||
- `toBlob(): Blob`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder maintains its internal state as private fields with `undefined`
|
||||
sentinels rather than partial-record objects so `exactOptionalPropertyTypes`
|
||||
in `tsconfig.base.json` does not punish the conditional spread in `build()`.
|
||||
The `build()` body uses conditional property assignment (`if (… !==
|
||||
undefined) candidate.x = …`) to avoid emitting `undefined` keys that Zod
|
||||
would reject under `exactOptionalPropertyTypes`. `toBlob()` calls `build()`
|
||||
so validation is enforced on every serialisation path — there is no escape
|
||||
hatch that bypasses Zod.
|
||||
66
docs/foundation/data/pwa/src/observability/observability.md
Normal file
66
docs/foundation/data/pwa/src/observability/observability.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# `observability/observability.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single factory function that builds the `PwaObservability` bundle threaded through
|
||||
every public manager in `@suaveplan/pwa`. Centralising instrument creation here keeps
|
||||
metric names stable across consumers, ensures every manager records on the same
|
||||
meter + tracer, and lets tests inject a no-op (or recording) bundle without
|
||||
touching `@opentelemetry/api` globals.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 counters covering service-worker install / activate / update, sync-queue
|
||||
enqueue / processed / failed, push subscriptions, install-prompt outcomes.
|
||||
- 2 histograms for sync-queue flush duration and cache-strategy handler latency.
|
||||
- Honors a pre-built `observability` option for end-to-end pass-through so
|
||||
composite consumers can share one bundle across multiple Suaveplan packages.
|
||||
- Falls back to `@opentelemetry/api`'s default no-op meter and tracer when no
|
||||
pipeline is registered — manager code can call `.add(1, attrs)` unconditionally.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPwaObservability } from "@suaveplan/pwa";
|
||||
|
||||
const obs = createPwaObservability();
|
||||
obs.counters.syncEnqueuedTotal.add(1, { kind: "post" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Reuse one observability bundle across multiple managers and a recording meter
|
||||
for tests:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { createPwaObservability } from "@suaveplan/pwa";
|
||||
|
||||
const meter = metrics.getMeter("test");
|
||||
const obs = createPwaObservability({ meter });
|
||||
const swm = new ServiceWorkerManager({ observability: obs });
|
||||
const queue = new SyncQueue({ observability: obs, store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPwaObservability(opts?: PwaObservabilityOptions): PwaObservability` —
|
||||
returns `{ meter, tracer, counters, histograms }`.
|
||||
- `PwaCounters` — 8 counter instruments.
|
||||
- `PwaHistograms` — 2 histogram instruments.
|
||||
- `PwaObservability` — top-level bundle type.
|
||||
- `PwaObservabilityOptions` — `{ observability?, meter?, tracer? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is deliberately stateless: invoking it twice yields two independent
|
||||
bundles bound to whatever meter / tracer the caller supplied. Counters are added
|
||||
via the shared `@suaveplan/opentelemetry` helpers so naming, attribute conventions,
|
||||
and unit handling stay consistent with the rest of the project. The `observability`
|
||||
short-circuit (pass-through when the caller already has a bundle) makes the bundle
|
||||
trivially composable: a single SuavePlan app can build one observability bundle at
|
||||
boot and thread it through every manager without re-creating instruments.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `push-notifications/push-notifications.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PushNotificationManager` wraps the W3C Push API and `Notification` static
|
||||
interface behind injectable seams. The manager handles support detection,
|
||||
permission queries + requests, subscription lifecycle (subscribe / unsubscribe
|
||||
/ get), and OTel emission. Every browser global the upstream `@suaveplan/pwa`
|
||||
poked via `vi.stubGlobal` is now reached through a constructor option so
|
||||
production wiring and test wiring share a single code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Permission state read + request via `NotificationStaticLike`.
|
||||
- VAPID `applicationServerKey` decoding via the package's
|
||||
`urlBase64ToUint8Array` helper.
|
||||
- Typed errors: `PWA.PUSH_UNSUPPORTED` when no `PushManager`/`Notification`
|
||||
is available, `PWA.PUSH_PERMISSION_DENIED` on user denial.
|
||||
- OTel counter increment on every successful subscribe.
|
||||
- Unsubscribe + `getSubscription()` return false / null gracefully on
|
||||
unsupported runtimes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PushNotificationManager } from "@suaveplan/pwa";
|
||||
|
||||
const push = new PushNotificationManager();
|
||||
const result = await push.subscribe({
|
||||
applicationServerKey: process.env.VAPID_PUBLIC_KEY,
|
||||
});
|
||||
if (result.subscribed) {
|
||||
await fetch("/api/push/subscriptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ endpoint: result.endpoint }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject fake container + notification static for tests:
|
||||
|
||||
```ts
|
||||
import { PushNotificationManager } from "@suaveplan/pwa";
|
||||
|
||||
const push = new PushNotificationManager({
|
||||
container: fakeContainer,
|
||||
notification: {
|
||||
permission: "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
});
|
||||
const result = await push.subscribe({ applicationServerKey: "BFa…" });
|
||||
expect(result.subscribed).toBe(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PushNotificationManager(options?: PushNotificationManagerOptions)`.
|
||||
- `isSupported: boolean`.
|
||||
- `getPermission(): PushPermission`.
|
||||
- `requestPermission(): Promise<PushPermission>`.
|
||||
- `subscribe(options?): Promise<PushSubscriptionResult>`.
|
||||
- `unsubscribe(): Promise<boolean>`.
|
||||
- `getSubscription(): Promise<PushSubscription | null>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager treats "permission default" as a denial path: returning a
|
||||
`PwaError(PUSH_PERMISSION_DENIED)` with the actual state in the message lets
|
||||
the caller distinguish the dismiss-without-deciding case from a hard denial
|
||||
without expanding the union beyond what the Push API supports. The
|
||||
`subscribe()` call always defaults `userVisibleOnly: true` because the spec
|
||||
requires it on all current browsers; if a caller explicitly opts to `false`
|
||||
their `pushManager.subscribe()` will reject and the error will surface up
|
||||
the await chain to the caller.
|
||||
60
docs/foundation/data/pwa/src/push-notifications/vapid.md
Normal file
60
docs/foundation/data/pwa/src/push-notifications/vapid.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# `push-notifications/vapid.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single-helper module that decodes a URL-safe base64 VAPID application server
|
||||
key into the raw `Uint8Array` the Push API's `pushManager.subscribe()` call
|
||||
expects. Web Push servers publish their keys in the URL-safe base64 form
|
||||
(`-` for `+`, `_` for `/`, optional `=` padding stripped) so this helper
|
||||
performs the canonicalisation step every PWA needs before passing the key to
|
||||
the platform.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function, no side effects, no DOM dependencies beyond `atob`.
|
||||
- Tolerates both padded and unpadded inputs — many servers emit one or the
|
||||
other and the helper normalises both to the same byte sequence.
|
||||
- Reuses the platform `atob` (available in every modern browser, Service
|
||||
Worker, Node 18+, and happy-dom) rather than shipping a base64 polyfill.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { urlBase64ToUint8Array } from "@suaveplan/pwa";
|
||||
|
||||
const key = urlBase64ToUint8Array(process.env.VAPID_PUBLIC_KEY!);
|
||||
await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Decode the same key in both padded and unpadded forms to assert they round
|
||||
trip identically (useful for boundary-condition tests):
|
||||
|
||||
```ts
|
||||
import { urlBase64ToUint8Array } from "@suaveplan/pwa";
|
||||
|
||||
const unpadded = "BIqi…rest87chars";
|
||||
const padded = `${unpadded}=`;
|
||||
const a = urlBase64ToUint8Array(unpadded);
|
||||
const b = urlBase64ToUint8Array(padded);
|
||||
expect(Array.from(a)).toEqual(Array.from(b));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `urlBase64ToUint8Array(key: string): Uint8Array`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function deliberately does NOT validate the resulting key length (Web Push
|
||||
VAPID public keys are 65 bytes per RFC 8292) because input shape validation
|
||||
belongs to the caller — the helper returns whatever the input encodes so the
|
||||
test surface and the production code take the same path. If callers want
|
||||
shape validation they wrap the call with a `Uint8Array.byteLength === 65`
|
||||
check. The byte loop is intentionally simple rather than using `Array.from` +
|
||||
`map`: a tight indexed loop avoids one `Array` allocation, which is measurable
|
||||
on large quantities of repeated key decodes (e.g. seeding a key cache).
|
||||
79
docs/foundation/data/pwa/src/schemas/schemas.md
Normal file
79
docs/foundation/data/pwa/src/schemas/schemas.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# `schemas/schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas guarding the package's two persisted boundaries: the sync-queue entry
|
||||
and the Web App Manifest. Every value crossing into IndexedDB / LocalStorage / a
|
||||
`Blob` payload is parsed through these schemas so corrupt or attacker-controlled
|
||||
data cannot survive a round-trip. The schemas also serve as the single source of
|
||||
truth for the inferred TypeScript types consumed throughout the package.
|
||||
|
||||
## Features
|
||||
|
||||
- `SyncQueueEntrySchema` — full structural validation including a strict
|
||||
`schemaVersion` literal so older entries are forced through `VersionMigration`
|
||||
before they validate.
|
||||
- `WebAppManifestSchema` — covers the spec-required surface (`name`, `icons`,
|
||||
`display`, `orientation`, `start_url`, `scope`, theme + background colour,
|
||||
`lang`, `dir`, shortcuts, related apps, share target).
|
||||
- `JsonValueSchema` — recursive JSON-safe value validator that rejects functions,
|
||||
symbols, and undefined so `metadata` never carries non-serialisable shapes.
|
||||
- Inferred types (`WebAppManifest`, `ManifestIcon`, `ManifestShortcut`,
|
||||
`RelatedApplication`, `ShareTarget`) for direct downstream use.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SyncQueueEntrySchema, SYNC_QUEUE_SCHEMA_VERSION } from "@suaveplan/pwa";
|
||||
|
||||
const parsed = SyncQueueEntrySchema.parse({
|
||||
id: "sq_1",
|
||||
url: "/api/v1/orders",
|
||||
method: "POST",
|
||||
createdAt: Date.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use `safeParse` to surface the validation error before deciding whether to drop
|
||||
the entry or migrate it:
|
||||
|
||||
```ts
|
||||
import { SyncQueueEntrySchema } from "@suaveplan/pwa";
|
||||
|
||||
const result = SyncQueueEntrySchema.safeParse(loadedRow);
|
||||
if (!result.success) {
|
||||
metrics.queueValidationFailures.add(1, {
|
||||
reason: result.error.issues[0]?.code ?? "unknown",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SyncQueueEntrySchema` — Zod schema for `SyncQueueEntry`.
|
||||
- `JsonValueSchema` — recursive JSON-safe value validator.
|
||||
- `ManifestIconSchema`, `ManifestShortcutSchema`, `RelatedApplicationSchema`,
|
||||
`ShareTargetSchema` — manifest sub-schemas.
|
||||
- `WebAppManifestSchema` — top-level manifest schema.
|
||||
- Inferred type aliases: `ManifestIcon`, `ManifestShortcut`,
|
||||
`RelatedApplication`, `ShareTarget`, `WebAppManifest`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`JsonValueSchema` uses `z.lazy(...)` so it can recurse through arbitrary JSON-like
|
||||
structures without blowing the Zod type-inference stack. The recursion intentionally
|
||||
forbids `undefined` (an allowed `JSON.stringify` no-op value) because IndexedDB
|
||||
silently drops `undefined` keys, which would mask data loss between enqueue and
|
||||
process. The manifest schema requires `name` and at least one icon, matching the
|
||||
two fields the W3C spec calls out as `MUST` for installability. Optional fields
|
||||
match the upstream specification field names (`short_name`, `start_url`, etc.) so
|
||||
the builder can output JSON directly without renaming.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `service-worker/service-worker.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ServiceWorkerManager` wraps the browser `ServiceWorkerContainer` API behind an
|
||||
injectable seam so production code reaches `navigator.serviceWorker` exactly once
|
||||
(via the constructor default) while tests pass an in-process fake container that
|
||||
drives every lifecycle transition deterministically. The manager handles
|
||||
registration, update polling, `skipWaiting` coordination, state-change emission,
|
||||
and OTel counter updates without any `vi.stubGlobal` shenanigans.
|
||||
|
||||
## Features
|
||||
|
||||
- Registration with scope, custom `scriptUrl`, `onUpdate` / `onSuccess` /
|
||||
`onError` callbacks, and configurable update-poll interval.
|
||||
- Lifecycle state machine — `installing → installed → activating → activated →
|
||||
redundant` plus `unsupported` for runtimes lacking `ServiceWorkerContainer`.
|
||||
- `skipWaiting()` posts `{ type: "SKIP_WAITING" }` to the waiting worker.
|
||||
- `unregister()` clears the update timer and tears down the registration.
|
||||
- `checkForUpdate()` invokes `registration.update()` and returns success.
|
||||
- Typed errors via `PWA_CODES.SW_UNSUPPORTED` / `PWA_CODES.SW_REGISTRATION_FAILED`.
|
||||
- OTel counters for `pwa_sw_installed_total`, `pwa_sw_activated_total`,
|
||||
`pwa_sw_updated_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ServiceWorkerManager } from "@suaveplan/pwa";
|
||||
|
||||
const swm = new ServiceWorkerManager();
|
||||
const result = await swm.register({ scriptUrl: "/sw.js" });
|
||||
if (!result.success && result.error !== undefined) {
|
||||
console.error(result.error.code, result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake container + interval scheduler for deterministic tests:
|
||||
|
||||
```ts
|
||||
import { ServiceWorkerManager } from "@suaveplan/pwa";
|
||||
|
||||
const swm = new ServiceWorkerManager({
|
||||
container: fakeContainer,
|
||||
intervals: {
|
||||
setInterval: (cb, ms) => fakeTimers.schedule(cb, ms),
|
||||
clearInterval: (h) => fakeTimers.cancel(h),
|
||||
},
|
||||
observability: testObs,
|
||||
});
|
||||
await swm.register({ scriptUrl: "/sw.js", updateInterval: 100 });
|
||||
fakeTimers.tick(100);
|
||||
expect(fakeContainer.lastRegistration?.update).toHaveBeenCalled();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ServiceWorkerManager(options?: ServiceWorkerManagerOptions)`.
|
||||
- `state: SwState` — current lifecycle state (readonly).
|
||||
- `registration: ServiceWorkerRegistration | null`.
|
||||
- `isSupported: boolean`.
|
||||
- `register(options): Promise<SwRegistrationResult>`.
|
||||
- `unregister(): Promise<boolean>`.
|
||||
- `checkForUpdate(): Promise<boolean>`.
|
||||
- `skipWaiting(): void`.
|
||||
- `onStateChange(listener): () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager never reaches `globalThis.navigator` from the body of any method —
|
||||
`this._container` is captured in the constructor and threaded through every
|
||||
subsequent call so an injected fake completely replaces the real container.
|
||||
The interval scheduler is a separate seam so tests can advance time without
|
||||
hooking the global timer, which under `happy-dom` + Vitest's fork pool can race
|
||||
the next test file. Errors surfaced from `container.register()` are wrapped in
|
||||
a `PwaError` with the original thrown error preserved as `cause`, so callers
|
||||
can `instanceof PwaError` + `error.code === "PWA.SW_REGISTRATION_FAILED"` and
|
||||
still recover the underlying `TypeError` / `SecurityError` via `error.cause`.
|
||||
66
docs/foundation/data/pwa/src/sync-queue/background-sync.md
Normal file
66
docs/foundation/data/pwa/src/sync-queue/background-sync.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# `sync-queue/background-sync.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges the standard W3C Background Sync API (`SyncManager.register(tag)`)
|
||||
into the `@suaveplan/pwa` package. Where the runtime supports it (every recent
|
||||
Chromium-based browser), the bridge registers a tag whose corresponding
|
||||
`sync` event the consumer's service worker handles. Where it does not (Firefox
|
||||
and Safari), the bridge falls back to a `visibilitychange` listener that
|
||||
invokes the supplied flush callback every time the tab transitions to the
|
||||
visible state — a close-enough approximation of "do work when the user comes
|
||||
back".
|
||||
|
||||
## Features
|
||||
|
||||
- Detects native support by inspecting the supplied registration for
|
||||
`registration.sync` rather than `navigator.serviceWorker`, so the bridge
|
||||
also works in worker contexts.
|
||||
- Visibility-change fallback fires exactly once per transition to `visible`
|
||||
(no spurious second invocations when the document is already visible).
|
||||
- `unregister()` removes the visibility listener; native registrations have
|
||||
no programmatic deregistration in the W3C spec, so the call is a no-op for
|
||||
that path.
|
||||
- `tag` getter exposes the currently-registered tag for diagnostic surfaces.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BackgroundSyncBridge } from "@suaveplan/pwa";
|
||||
|
||||
const bridge = new BackgroundSyncBridge({ registration, document });
|
||||
await bridge.register("retry-pending", () => queue.process());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inspect the returned mode to differ telemetry per platform:
|
||||
|
||||
```ts
|
||||
import { BackgroundSyncBridge } from "@suaveplan/pwa";
|
||||
|
||||
const bridge = new BackgroundSyncBridge({ registration, document });
|
||||
const mode = await bridge.register("retry-pending", flushQueue);
|
||||
metrics.bsyncRegistered.add(1, { mode });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new BackgroundSyncBridge(options: BackgroundSyncBridgeOptions)`.
|
||||
- `register(tag, flush): Promise<"native" | "visibility">`.
|
||||
- `unregister(): void`.
|
||||
- `tag: string | null`.
|
||||
- `SyncManagerLike`, `SyncCapableRegistration`, `VisibilityDocumentLike` —
|
||||
injectable seams.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The visibility-fallback listener compares the current `visibilityState`
|
||||
against a private `_lastState` so it only fires when the document transitions
|
||||
into the visible state from any other state, not on the no-op event that
|
||||
fires when a tab is opened in the foreground. This keeps the flush callback's
|
||||
invocation count proportional to actual user activity rather than to the
|
||||
number of `visibilitychange` events the browser happens to dispatch. The
|
||||
`register()` body intentionally requires either a `sync`-capable registration
|
||||
or a document seam — passing neither is a programming error and surfaces as a
|
||||
thrown `Error` rather than silently no-opping.
|
||||
61
docs/foundation/data/pwa/src/sync-queue/indexeddb-store.md
Normal file
61
docs/foundation/data/pwa/src/sync-queue/indexeddb-store.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# `sync-queue/indexeddb-store.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Default production `SyncQueueStore` implementation, backed by IndexedDB. IDB
|
||||
is the only browser-native storage that survives the 5 MiB `localStorage`
|
||||
budget and supports async, transactional persistence — both essential for
|
||||
queues that may temporarily accumulate large POST bodies while offline. The
|
||||
store exposes the standard `load()` + `save()` shape of every other
|
||||
`SyncQueueStore` and swaps in transparently behind `SyncQueue`.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `IDBFactory` — production uses `globalThis.indexedDB`, tests use
|
||||
`fake-indexeddb` or an equivalent in-process implementation.
|
||||
- Configurable database and object-store names so multiple `SyncQueue`
|
||||
instances can persist independently inside the same origin.
|
||||
- Full transactional save: every persist clears the store and re-inserts the
|
||||
current snapshot in a single `readwrite` transaction; partial writes from a
|
||||
crash leave the store in its previous coherent state.
|
||||
- Defensive rejection of edge errors (`onerror`, `onabort`, `onblocked`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IndexedDbStore, SyncQueue } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new IndexedDbStore() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake factory for deterministic tests and per-feature databases:
|
||||
|
||||
```ts
|
||||
import { IndexedDbStore } from "@suaveplan/pwa";
|
||||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||
|
||||
const fakeFactory = new FDBFactory() as unknown as IDBFactory;
|
||||
const store = new IndexedDbStore({
|
||||
factory: fakeFactory,
|
||||
databaseName: "test_db",
|
||||
objectStoreName: "test_queue",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new IndexedDbStore(options?: IndexedDbStoreOptions)`.
|
||||
- `load(): Promise<Record<string, unknown>[]>`.
|
||||
- `save(entries: readonly SyncQueueEntry[]): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The save path issues `store.clear()` followed by per-entry `store.put()` inside
|
||||
a single `readwrite` transaction. Because IDB transactions commit atomically,
|
||||
either every entry lands or none of them do — a crash mid-save leaves the
|
||||
previous queue intact. Connections are closed in a `finally` block so the
|
||||
store does not pin an open connection across reads/writes, which would prevent
|
||||
schema upgrades from completing. The keypath is `"id"` so `put()` upserts on
|
||||
the existing primary key without inflating row count on overwrite.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `sync-queue/local-storage-store.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Backwards-compatible `SyncQueueStore` backed by the synchronous `Storage`
|
||||
interface (`localStorage` in production). Retained primarily so consumers
|
||||
migrating from `@suaveplan/pwa@1.0.4` can re-read their persisted queues on
|
||||
first load before being switched to the IndexedDB-backed store. The
|
||||
implementation accepts any `StorageLike` seam, so tests and Service Worker
|
||||
contexts (which lack `localStorage`) can substitute an in-memory fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable storage seam and storage key (`__pwa_sync_queue__` by default).
|
||||
- Falls back to an in-process `MemoryStorage` when neither
|
||||
`options.storage` nor `globalThis.localStorage` is available — keeps the
|
||||
module loadable from a SW context for diagnostic queries.
|
||||
- Tolerates corrupt JSON: a parse error returns `[]` rather than throwing, so
|
||||
one malformed write cannot brick the queue forever.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LocalStorageStore, SyncQueue } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new LocalStorageStore() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pin the store to a custom key and inject a test storage:
|
||||
|
||||
```ts
|
||||
import { LocalStorageStore } from "@suaveplan/pwa";
|
||||
|
||||
const memory = new Map<string, string>();
|
||||
const fakeStorage = {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void memory.set(k, v),
|
||||
removeItem: (k: string) => void memory.delete(k),
|
||||
};
|
||||
const store = new LocalStorageStore({
|
||||
storage: fakeStorage,
|
||||
key: "__custom_queue__",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LocalStorageStore(options?: LocalStorageStoreOptions)`.
|
||||
- `load(): Promise<Record<string, unknown>[]>`.
|
||||
- `save(entries: readonly SyncQueueEntry[]): Promise<void>`.
|
||||
- `createMemoryStorage(): StorageLike` — last-resort `Map`-backed factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The store treats a non-array JSON payload as the empty queue rather than
|
||||
throwing — this defensively normalises legacy `null` writes from earlier
|
||||
versions of `@suaveplan/pwa` where corrupt writes could survive. The exported
|
||||
`createMemoryStorage` factory is used as the constructor's last-resort
|
||||
fallback when neither an injected seam nor `globalThis.localStorage` is
|
||||
available; it implements the narrow three-method `StorageLike` contract
|
||||
rather than the full DOM `Storage` interface to keep the bundle small. Writes serialise the entire
|
||||
queue snapshot every time; for queues approaching `localStorage`'s 5 MiB
|
||||
budget, switch to `IndexedDbStore`.
|
||||
62
docs/foundation/data/pwa/src/sync-queue/memory-store.md
Normal file
62
docs/foundation/data/pwa/src/sync-queue/memory-store.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `sync-queue/memory-store.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Fastest possible `SyncQueueStore` implementation, backed by a private array.
|
||||
Used as the default in unit tests and as a fallback in environments where
|
||||
neither `localStorage` nor `indexedDB` is available (notably inside a Service
|
||||
Worker global scope, where persistence must be managed via `Cache` or a side
|
||||
channel). Entries are deep-copied on every read and write so callers cannot
|
||||
mutate the store's internal state by retaining a reference.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous semantics wrapped in `Promise.resolve` so it conforms to the
|
||||
async `SyncQueueStore` contract identically to the IDB-backed store.
|
||||
- Optional `seed` array for tests that want a pre-populated queue.
|
||||
- Defensive copies on `load()` and `save()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MemoryStore, SyncQueue } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Seed a queue with entries that need processing on the very first `process()`
|
||||
call (useful for replaying a snapshot):
|
||||
|
||||
```ts
|
||||
import { MemoryStore } from "@suaveplan/pwa";
|
||||
|
||||
const seeded = new MemoryStore([
|
||||
{
|
||||
id: "sq_1",
|
||||
url: "/api/x",
|
||||
method: "POST",
|
||||
createdAt: Date.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: 2,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MemoryStore(seed?: readonly SyncQueueEntry[])`.
|
||||
- `load(): Promise<Record<string, unknown>[]>`.
|
||||
- `save(entries: readonly SyncQueueEntry[]): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The store always copies entries via `{ ...e }` rather than returning the raw
|
||||
array, so a caller that mutates a returned entry never corrupts the store's
|
||||
internal state. The shallow copy is sufficient because `SyncQueueEntry`
|
||||
contains only primitives, optional plain records, and optional plain strings —
|
||||
no nested mutable references that need a deeper clone. The synchronous body
|
||||
wrapped in `async` keeps the interface trivially substitutable with the IDB
|
||||
and LocalStorage stores without any caller-side branching.
|
||||
87
docs/foundation/data/pwa/src/sync-queue/sync-queue.md
Normal file
87
docs/foundation/data/pwa/src/sync-queue/sync-queue.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# `sync-queue/sync-queue.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Offline-first request queue that persists requests while the device is offline
|
||||
and replays them when connectivity returns. The queue is backend-agnostic: it
|
||||
delegates persistence to any `SyncQueueStore` (memory, `localStorage`, or
|
||||
IndexedDB), validates every entry through `SyncQueueEntrySchema` at the
|
||||
enqueue boundary, runs an exponential-backoff retry loop, and emits OTel
|
||||
counters and histograms covering enqueue / processed / failed counts plus
|
||||
flush duration. Migration from older schema versions happens automatically on
|
||||
every load.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable store (`MemoryStore`, `LocalStorageStore`, `IndexedDbStore`).
|
||||
- Zod boundary validation on every enqueue; failures raise
|
||||
`PwaError(SYNC_QUEUE_VALIDATION)`.
|
||||
- Capacity enforcement via `PwaError(SYNC_QUEUE_FULL)` when `maxSize` is hit.
|
||||
- Auto-migration of older schema versions on every load (idempotent).
|
||||
- Single-flight `process()` — concurrent invocations short-circuit to `0`.
|
||||
- Exponential-backoff `retryDelayFor(retryCount)` accessor.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic tests.
|
||||
- OTel emission: `pwa_sync_enqueued_total`, `pwa_sync_processed_total`,
|
||||
`pwa_sync_failed_total`, `pwa_sync_flush_ms`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SyncQueue, IndexedDbStore } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new IndexedDbStore() });
|
||||
await queue.enqueue("/api/v1/orders", "POST", {
|
||||
body: JSON.stringify({ sku: "abc", qty: 2 }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
await queue.process();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a clock + id generator + custom processor + failure hook for tests:
|
||||
|
||||
```ts
|
||||
import { SyncQueue, MemoryStore, type Clock } from "@suaveplan/pwa";
|
||||
|
||||
const clock: Clock = { now: () => 1_700_000_000_000 };
|
||||
let n = 0;
|
||||
const queue = new SyncQueue({
|
||||
store: new MemoryStore(),
|
||||
clock,
|
||||
idGenerator: () => `sq_${++n}`,
|
||||
maxRetries: 2,
|
||||
retryDelay: 100,
|
||||
retryBackoffFactor: 3,
|
||||
onProcess: async (entry) => entry.url !== "/fail",
|
||||
onFailure: (entry) => console.error("permanently failed", entry.id),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SyncQueue(options: SyncQueueOptions)`.
|
||||
- `enqueue(url, method, options?): Promise<SyncQueueEntry>`.
|
||||
- `remove(id): Promise<boolean>`.
|
||||
- `getAll(): Promise<SyncQueueEntry[]>`.
|
||||
- `size(): Promise<number>`.
|
||||
- `clear(): Promise<void>`.
|
||||
- `process(): Promise<number>`.
|
||||
- `retryDelayFor(retryCount): number`.
|
||||
- `isProcessing: boolean` (readonly).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The queue serialises the entire snapshot on every mutation rather than
|
||||
performing per-entry CRUD against the underlying store. This keeps the
|
||||
`SyncQueueStore` contract minimal (`load` / `save`) and lets every store
|
||||
implementation guarantee atomicity simply by wrapping `save()` in its native
|
||||
transaction primitive. The `process()` body avoids `Promise.all` so failures
|
||||
in one entry cannot cancel sibling work; instead each entry is awaited
|
||||
sequentially and its outcome recorded. The histogram records the entire flush
|
||||
duration including loads and saves so the observed value matches user-felt
|
||||
latency rather than CPU-only time.
|
||||
69
docs/foundation/data/pwa/src/sync-queue/version-migration.md
Normal file
69
docs/foundation/data/pwa/src/sync-queue/version-migration.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `sync-queue/version-migration.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Forward-migrates raw `SyncQueueEntry` records loaded from persistent storage
|
||||
(IndexedDB / localStorage) to the current schema version. Entries created by
|
||||
older versions of `@suaveplan/pwa` linger in browser storage indefinitely, so
|
||||
the queue must upgrade them on every load before they cross the
|
||||
`SyncQueueEntrySchema` validator. The migration registry is deterministic and
|
||||
idempotent: an entry already at the current version passes through unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_MIGRATORS` — ordered list of `VersionMigrator` records shipped with
|
||||
the package, currently covering `v1 → v2` (the leap that added an explicit
|
||||
`schemaVersion` field).
|
||||
- `migrateEntry(raw, migrators?)` — single-entry upgrade.
|
||||
- `migrateBatch(raws, migrators?)` — convenience for bulk loads.
|
||||
- `asSyncQueueEntry(record)` — type narrowing for callers that have just
|
||||
validated a record against `SyncQueueEntrySchema`.
|
||||
- Throws when no migrator is registered for a `fromVersion`, so corrupt
|
||||
versions surface as errors rather than silently propagating.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { migrateEntry } from "@suaveplan/pwa";
|
||||
|
||||
const upgraded = migrateEntry({
|
||||
id: "old",
|
||||
url: "/api/x",
|
||||
method: "POST",
|
||||
createdAt: 0,
|
||||
});
|
||||
// upgraded.schemaVersion === 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Supply an extra migrator for a custom future schema bump:
|
||||
|
||||
```ts
|
||||
import { DEFAULT_MIGRATORS, migrateBatch, type VersionMigrator } from "@suaveplan/pwa";
|
||||
|
||||
const v2ToV3: VersionMigrator = {
|
||||
fromVersion: 2,
|
||||
upgrade: (e) => ({ ...e, schemaVersion: 3, tenant: e.tenant ?? "default" }),
|
||||
};
|
||||
const upgraded = migrateBatch(rawRows, [...DEFAULT_MIGRATORS, v2ToV3]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VersionMigrator` — `{ fromVersion, upgrade(entry) }`.
|
||||
- `DEFAULT_MIGRATORS` — frozen array shipped with the package.
|
||||
- `migrateEntry(raw, migrators?): Record<string, unknown>`.
|
||||
- `migrateBatch(raws, migrators?): Record<string, unknown>[]`.
|
||||
- `asSyncQueueEntry(record): SyncQueueEntry` — narrowing cast helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function operates on `Record<string, unknown>` rather than `SyncQueueEntry`
|
||||
because pre-current-version records do not satisfy the current type. The caller
|
||||
is expected to feed the output through `SyncQueueEntrySchema.parse(...)` before
|
||||
trusting field types. The loop terminates because every migrator must increment
|
||||
`schemaVersion` by at least one (enforced via the throw on missing migrator
|
||||
when a record's version is `>= SYNC_QUEUE_SCHEMA_VERSION` we don't enter the
|
||||
loop at all). Idempotency derives from the same property: a record already at
|
||||
the current version skips the loop body entirely.
|
||||
88
docs/foundation/data/pwa/src/types/common.md
Normal file
88
docs/foundation/data/pwa/src/types/common.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# `types/common.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Central type definitions shared across the `@suaveplan/pwa` package. The module owns
|
||||
every cross-module structural contract — service-worker state, sync-queue entry shape,
|
||||
push subscription results, install-prompt state machine values, clock seam, window-like
|
||||
event target, storage-like persistence, and notification-static seam — so individual
|
||||
feature modules can be re-implemented or replaced without forcing renames across the
|
||||
package. The file is types-only; no runtime values escape except `SYSTEM_CLOCK`, the
|
||||
canonical `Date.now()`-backed `Clock` instance every consumer can adopt without
|
||||
constructing their own.
|
||||
|
||||
## Features
|
||||
|
||||
- `SwState` enum (`installing`, `installed`, `activating`, `activated`, `redundant`,
|
||||
`unsupported`) used by `ServiceWorkerManager`.
|
||||
- `SwRegistrationOptions` and `SwRegistrationResult` types for registration calls.
|
||||
- `SyncQueueEntry` (with `schemaVersion`) and `SyncQueueConfig`.
|
||||
- `PushPermission`, `PushNotificationOptions`, `PushSubscriptionResult`.
|
||||
- `InstallPromptState`, `InstallPromptListener` for the install-prompt state machine.
|
||||
- `Clock` + `SYSTEM_CLOCK` runtime seam.
|
||||
- `WindowLike`, `StorageLike`, `NotificationStaticLike` injectable seams that match
|
||||
the relevant DOM contracts narrowly so production wiring is a no-op cast.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SYSTEM_CLOCK,
|
||||
type SyncQueueEntry,
|
||||
type SwState,
|
||||
} from "@suaveplan/pwa";
|
||||
|
||||
const state: SwState = "installing";
|
||||
const entry: SyncQueueEntry = {
|
||||
id: "abc",
|
||||
url: "/api/v1/posts",
|
||||
method: "POST",
|
||||
createdAt: SYSTEM_CLOCK.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: 2,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Substitute the clock to make every PWA timestamp deterministic in tests:
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/pwa";
|
||||
|
||||
const fakeClock: Clock = {
|
||||
now: () => 1_700_000_000_000,
|
||||
};
|
||||
const queue = new SyncQueue({ clock: fakeClock, store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SwState` — discriminated union of every lifecycle value the manager exposes.
|
||||
- `SwRegistrationOptions` — registration parameters consumed by
|
||||
`ServiceWorkerManager.register()`.
|
||||
- `SwRegistrationResult` — `{ success, state, error? }`.
|
||||
- `SyncQueueEntry` — request descriptor persisted by the queue.
|
||||
- `SyncQueueConfig` — pluggable processor + failure callbacks + retry tuning.
|
||||
- `PushPermission` — `"granted" | "denied" | "default"`.
|
||||
- `PushNotificationOptions` — VAPID key + user-visibility flag.
|
||||
- `PushSubscriptionResult` — subscription outcome.
|
||||
- `InstallPromptState` / `InstallPromptListener` — install-prompt FSM.
|
||||
- `Clock` — `{ now(): number }`.
|
||||
- `SYSTEM_CLOCK` — `Date.now()`-backed clock.
|
||||
- `WindowLike`, `StorageLike`, `NotificationStaticLike` — narrow injectable contracts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file is intentionally pure types plus a single frozen constant. It carries no
|
||||
side effects and no behaviour beyond declaring shapes. The `Clock`, `WindowLike`,
|
||||
`StorageLike`, and `NotificationStaticLike` seams duplicate the relevant lib.dom.d.ts
|
||||
slices structurally so production code can pass `navigator.serviceWorker`, `window`,
|
||||
`localStorage`, and `Notification` without casts. The `SYSTEM_CLOCK` literal is the
|
||||
only runtime symbol exposed, and it is a fully-frozen object with one method, so the
|
||||
package keeps `"sideEffects": false` viable in `package.json`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# `update-controller/update-controller.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`UpdateController` coordinates a synchronised page reload across every tab the
|
||||
application is open in when a new service worker activates. It opens a single
|
||||
`BroadcastChannel`, listens for a typed `{ type: "RELOAD" }` envelope, and
|
||||
invokes `window.location.reload()` on receipt. The controller solves the
|
||||
"tab A is already running the new SW while tabs B/C are still serving the old
|
||||
one" problem that bites every multi-tab PWA without explicit cross-tab
|
||||
coordination.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `BroadcastChannel` per controller instance, named
|
||||
`@suaveplan/pwa/update` by default.
|
||||
- Typed message envelope (`UpdateMessage`) so consumers cannot accidentally
|
||||
broadcast unrelated payloads on the same channel.
|
||||
- Injectable `BroadcastChannelLike` factory + `WindowLike` seam — production
|
||||
uses the platform `BroadcastChannel` + `window`, tests inject a shared
|
||||
in-memory hub that lets multiple controllers observe each other's messages.
|
||||
- `close()` removes listeners and closes the channel so the controller can be
|
||||
discarded after activation, preventing leaks in single-page apps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { UpdateController } from "@suaveplan/pwa";
|
||||
|
||||
const updates = new UpdateController();
|
||||
swm.onStateChange((state) => {
|
||||
if (state === "activated") updates.signalReload();
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Test two controllers communicating through one shared in-memory channel hub:
|
||||
|
||||
```ts
|
||||
import { UpdateController } from "@suaveplan/pwa";
|
||||
|
||||
const hub = createSharedChannelHub();
|
||||
const reloaded = { a: 0, b: 0 };
|
||||
const a = new UpdateController({
|
||||
broadcastChannelFactory: (n) => hub.open(n),
|
||||
window: { location: { reload: () => reloaded.a++ } },
|
||||
});
|
||||
const b = new UpdateController({
|
||||
broadcastChannelFactory: (n) => hub.open(n),
|
||||
window: { location: { reload: () => reloaded.b++ } },
|
||||
});
|
||||
a.signalReload();
|
||||
// receivers run synchronously inside the hub
|
||||
expect(reloaded.b).toBe(1);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new UpdateController(options?: UpdateControllerOptions)`.
|
||||
- `signalReload(): void`.
|
||||
- `close(): void`.
|
||||
- `UpdateMessage` — `{ type: "RELOAD" }`.
|
||||
- `BroadcastChannelLike`, `BroadcastChannelFactory`, `UpdateWindowLike` — seams.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The controller does not invoke `window.location.reload()` in response to its
|
||||
own broadcast — `BroadcastChannel` already excludes the sending context from
|
||||
receipt, so the originating tab is expected to reload via the SW's own
|
||||
`controllerchange` path. The injected `WindowLike` is optional so the
|
||||
controller can also be constructed inside a SW global scope (which has no
|
||||
`window`) for diagnostic purposes; in that mode reloads are no-ops.
|
||||
Reference in New Issue
Block a user