first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View 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).

View 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.

View File

@@ -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`.

View 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.

View 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.

View File

@@ -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`.

View 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.

View 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.

View 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.

View 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`.

View File

@@ -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.