Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,113 @@
{
"name": "@suaveplan/pwa",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/observability.d.ts",
"default": "./dist/observability/observability.js"
}
},
"./service-worker": {
"import": {
"types": "./dist/service-worker/service-worker.d.ts",
"default": "./dist/service-worker/service-worker.js"
}
},
"./update-controller": {
"import": {
"types": "./dist/update-controller/update-controller.d.ts",
"default": "./dist/update-controller/update-controller.js"
}
},
"./sync-queue": {
"import": {
"types": "./dist/sync-queue/sync-queue.d.ts",
"default": "./dist/sync-queue/sync-queue.js"
}
},
"./push-notifications": {
"import": {
"types": "./dist/push-notifications/push-notifications.d.ts",
"default": "./dist/push-notifications/push-notifications.js"
}
},
"./install-prompt": {
"import": {
"types": "./dist/install-prompt/install-prompt.d.ts",
"default": "./dist/install-prompt/install-prompt.js"
}
},
"./caching-strategies": {
"import": {
"types": "./dist/caching-strategies/caching-strategies.d.ts",
"default": "./dist/caching-strategies/caching-strategies.js"
}
},
"./manifest": {
"import": {
"types": "./dist/manifest/manifest.d.ts",
"default": "./dist/manifest/manifest.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fake-indexeddb": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

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,234 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "../types/common";
import {
cacheFirst,
cacheOnly,
networkFirst,
networkOnly,
staleWhileRevalidate,
} from "./caching-strategies";
import type { StrategyContext } from "./caching-strategies.types";
class FakeCache implements Partial<Cache> {
public store = new Map<string, Response>();
public async match(
request: RequestInfo | URL
): Promise<Response | undefined> {
const key =
typeof request === "string" ? request : (request as Request).url;
const r = this.store.get(key);
return r === undefined ? undefined : r.clone();
}
public async put(
request: RequestInfo | URL,
response: Response
): Promise<void> {
const key =
typeof request === "string" ? request : (request as Request).url;
this.store.set(key, response);
}
}
function buildContext(opts: {
fetcher: typeof fetch;
clock?: Clock;
}): StrategyContext {
return {
cache: new FakeCache() as unknown as Cache,
fetcher: opts.fetcher,
clock: opts.clock ?? { now: () => 1_000 },
};
}
let calls: number;
function counted(body: string, status = 200): typeof fetch {
return ((_req: unknown) => {
calls += 1;
return Promise.resolve(new Response(body, { status }));
}) as unknown as typeof fetch;
}
beforeEach(() => {
calls = 0;
});
describe("cacheFirst", () => {
it("cacheFirst hits cache when present and falls through otherwise", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("net") });
const strategy = cacheFirst();
const req = new Request("https://x.test/a");
const a = await strategy(req, ctx);
expect(await a.text()).toBe("net");
expect(calls).toBe(1);
const b = await strategy(req, ctx);
expect(await b.text()).toBe("net");
expect(calls).toBe(1);
});
it("cacheFirst refetches when TTL expires", { timeout: 5000 }, async () => {
let t = 0;
const clock: Clock = { now: () => t };
const ctx = buildContext({ fetcher: counted("net"), clock });
const strategy = cacheFirst({ ttlMs: 100 });
const req = new Request("https://x.test/a");
await strategy(req, ctx);
t = 200;
await strategy(req, ctx);
expect(calls).toBe(2);
});
});
describe("networkFirst", () => {
it("networkFirst falls back to cache on network failure", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("ok") });
const req = new Request("https://x.test/n");
const strategy = networkFirst();
// seed cache
await strategy(req, ctx);
// now make the fetcher reject
(ctx as { fetcher: typeof fetch }).fetcher = (() => {
return Promise.reject(new Error("down"));
}) as unknown as typeof fetch;
const response = await strategy(req, ctx);
expect(response.status).toBe(200);
expect(await response.text()).toBe("ok");
});
it("networkFirst returns 504 on hard offline with no cache", {
timeout: 5000,
}, async () => {
const ctx = buildContext({
fetcher: (() =>
Promise.reject(new Error("down"))) as unknown as typeof fetch,
});
const req = new Request("https://x.test/n");
const response = await networkFirst()(req, ctx);
expect(response.status).toBe(504);
});
it("networkFirst returns 504 when only stale cache exists", {
timeout: 5000,
}, async () => {
let t = 0;
const clock: Clock = { now: () => t };
const ctx = buildContext({ fetcher: counted("ok"), clock });
const req = new Request("https://x.test/n");
const strategy = networkFirst({ ttlMs: 50 });
await strategy(req, ctx);
t = 200;
(ctx as { fetcher: typeof fetch }).fetcher = (() =>
Promise.reject(new Error("down"))) as unknown as typeof fetch;
const response = await strategy(req, ctx);
expect(response.status).toBe(504);
});
});
describe("staleWhileRevalidate", () => {
it("staleWhileRevalidate refreshes in the background", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("v1") });
const req = new Request("https://x.test/swr");
const strategy = staleWhileRevalidate();
const a = await strategy(req, ctx);
expect(await a.text()).toBe("v1");
// change fetcher response and call again — should serve cached v1 but trigger revalidate
(ctx as { fetcher: typeof fetch }).fetcher = ((_r: unknown) => {
return Promise.resolve(new Response("v2"));
}) as unknown as typeof fetch;
const b = await strategy(req, ctx);
expect(await b.text()).toBe("v1");
// give the background fetch a tick
await new Promise((r) => setTimeout(r, 5));
const c = await strategy(req, ctx);
expect(await c.text()).toBe("v2");
});
it("staleWhileRevalidate returns 504 on miss + fetch failure", {
timeout: 5000,
}, async () => {
const ctx = buildContext({
fetcher: (() =>
Promise.reject(new Error("down"))) as unknown as typeof fetch,
});
const req = new Request("https://x.test/swr2");
const response = await staleWhileRevalidate()(req, ctx);
expect(response.status).toBe(504);
});
});
describe("cacheOnly", () => {
it("cacheOnly returns 504 on miss", { timeout: 5000 }, async () => {
const ctx = buildContext({ fetcher: counted("never") });
const response = await cacheOnly()(
new Request("https://x.test/co"),
ctx
);
expect(response.status).toBe(504);
expect(calls).toBe(0);
});
it("cacheOnly returns the cached value when present", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("net") });
const req = new Request("https://x.test/co2");
await cacheFirst()(req, ctx); // seed
const response = await cacheOnly()(req, ctx);
expect(await response.text()).toBe("net");
});
});
describe("edge branches", () => {
it("non-ok responses are returned without caching", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("err", 500) });
const req = new Request("https://x.test/err");
const strategy = cacheFirst();
const a = await strategy(req, ctx);
expect(a.status).toBe(500);
const b = await strategy(req, ctx);
expect(b.status).toBe(500);
expect(calls).toBe(2);
});
it("cacheFirst returns cached when no TTL", { timeout: 5000 }, async () => {
const ctx = buildContext({ fetcher: counted("seeded") });
const req = new Request("https://x.test/seeded");
const strategy = cacheFirst();
await strategy(req, ctx);
const second = await strategy(req, ctx);
expect(await second.text()).toBe("seeded");
expect(calls).toBe(1);
});
it("treats cached responses without a timestamp header as fresh", {
timeout: 5000,
}, async () => {
const ctx = buildContext({ fetcher: counted("fresh-net") });
const req = new Request("https://x.test/unstamped");
// Seed the cache directly with an unstamped response (no x-suaveplan-pwa-cached-at).
await ctx.cache.put(req, new Response("pre-existing"));
const strategy = cacheFirst({ ttlMs: 100 });
const response = await strategy(req, ctx);
expect(await response.text()).toBe("pre-existing");
expect(calls).toBe(0);
});
});
describe("networkOnly", () => {
it("networkOnly never reads the cache", { timeout: 5000 }, async () => {
const ctx = buildContext({ fetcher: counted("fresh") });
const req = new Request("https://x.test/no");
await cacheFirst()(req, ctx); // would seed cache
const response = await networkOnly()(req, ctx);
expect(await response.text()).toBe("fresh");
// 1 fetch from cacheFirst + 1 from networkOnly
expect(calls).toBe(2);
});
});

View File

@@ -0,0 +1,121 @@
import type {
Strategy,
StrategyContext,
StrategyOptions,
} from "./caching-strategies.types";
const TIMESTAMP_HEADER = "x-suaveplan-pwa-cached-at";
function nowFromContext(context: StrategyContext): number {
return context.clock.now();
}
async function cachePut(
cache: Cache,
request: Request,
response: Response,
timestamp: number
): Promise<Response> {
if (!response.ok) return response;
const headers = new Headers(response.headers);
headers.set(TIMESTAMP_HEADER, String(timestamp));
const stamped = new Response(response.clone().body, {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(request, stamped.clone());
return stamped;
}
function isExpired(
response: Response,
ttlMs: number | undefined,
now: number
): boolean {
if (ttlMs === undefined || ttlMs <= 0) return false;
const stamp = response.headers.get(TIMESTAMP_HEADER);
if (stamp === null) return false;
return now - Number(stamp) >= ttlMs;
}
/** Cache-first: return cache when present + fresh; else fetch + cache. */
export function cacheFirst(options: StrategyOptions = {}): Strategy {
return async (request, context) => {
const now = nowFromContext(context);
const cached = await context.cache.match(request);
if (cached !== undefined && !isExpired(cached, options.ttlMs, now)) {
return cached;
}
const response = await context.fetcher(request);
return cachePut(context.cache, request, response, now);
};
}
/** Network-first: fetch + cache; fall back to cache on network failure. */
export function networkFirst(options: StrategyOptions = {}): Strategy {
return async (request, context) => {
const now = nowFromContext(context);
try {
const response = await context.fetcher(request);
return cachePut(context.cache, request, response, now);
} catch {
const cached = await context.cache.match(request);
if (cached === undefined) {
return new Response("Offline", {
status: 504,
statusText: "Gateway Timeout",
});
}
if (isExpired(cached, options.ttlMs, now)) {
return new Response("Stale cache only", {
status: 504,
statusText: "Gateway Timeout",
});
}
return cached;
}
};
}
/** Stale-while-revalidate: return cache immediately, refresh asynchronously. */
export function staleWhileRevalidate(options: StrategyOptions = {}): Strategy {
return async (request, context) => {
const now = nowFromContext(context);
const cached = await context.cache.match(request);
const fetchPromise = context
.fetcher(request)
.then((response) => cachePut(context.cache, request, response, now))
.catch(() => {});
if (cached !== undefined && !isExpired(cached, options.ttlMs, now)) {
// fire-and-forget refresh
void fetchPromise;
return cached;
}
const refreshed = await fetchPromise;
if (refreshed === undefined) {
return new Response("Unavailable", {
status: 504,
statusText: "Gateway Timeout",
});
}
return refreshed;
};
}
/** Cache-only: return cache or 504; never fetch. */
export function cacheOnly(_options: StrategyOptions = {}): Strategy {
return async (request, context) => {
const cached = await context.cache.match(request);
if (cached !== undefined) return cached;
return new Response("Not in cache", {
status: 504,
statusText: "Gateway Timeout",
});
};
}
/** Network-only: always fetch, never read or write cache. */
export function networkOnly(_options: StrategyOptions = {}): Strategy {
return async (request, context) => context.fetcher(request);
}

View File

@@ -0,0 +1,20 @@
import type { Clock } from "../types/common";
/** Context every strategy receives. */
export interface StrategyContext {
readonly cache: Cache;
readonly fetcher: typeof fetch;
readonly clock: Clock;
}
/** Strategy = pure async function from `(Request, StrategyContext) → Response`. */
export type Strategy = (
request: Request,
context: StrategyContext
) => Promise<Response>;
/** Common options consumed by the built-in strategy factories. */
export interface StrategyOptions {
/** TTL in milliseconds. When set, cached responses older than `ttlMs` are treated as expired. */
ttlMs?: number;
}

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,293 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPwaObservability } from "../observability/observability";
import { cacheFirst, networkFirst } from "./caching-strategies";
import { Router } from "./router";
class FakeCache implements Partial<Cache> {
public store = new Map<string, Response>();
public async match(
request: RequestInfo | URL
): Promise<Response | undefined> {
const key =
typeof request === "string" ? request : (request as Request).url;
const r = this.store.get(key);
return r === undefined ? undefined : r.clone();
}
public async put(
request: RequestInfo | URL,
response: Response
): Promise<void> {
const key =
typeof request === "string" ? request : (request as Request).url;
this.store.set(key, response);
}
}
class FakeCacheStorage implements Partial<CacheStorage> {
public caches = new Map<string, FakeCache>();
public async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (cache === undefined) {
cache = new FakeCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
}
function makeFetcher(): {
fetcher: typeof fetch;
calls: { url: string; method: string }[];
} {
const calls: { url: string; method: string }[] = [];
const fetcher = ((input: RequestInfo) => {
const req =
typeof input === "string" ? new Request(input) : (input as Request);
calls.push({ url: req.url, method: req.method });
return Promise.resolve(new Response("ok"));
}) as unknown as typeof fetch;
return { fetcher, calls };
}
describe("Router", () => {
it("matches on method and pattern; falls through otherwise", {
timeout: 5000,
}, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher, calls } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
router.registerRoute({
method: "GET",
pattern: /\/api\//,
strategy: cacheFirst(),
});
await router.handle(new Request("https://x.test/api/x"));
await router.handle(
new Request("https://x.test/api/y", { method: "POST" })
);
// The POST request should fall through (no match) and hit the fetcher unconditionally.
// Both GET (1 cache miss fetch) and POST (fall-through fetch) → 2 fetches.
expect(calls.length).toBe(2);
});
it("evaluates routes in registration order", {
timeout: 5000,
}, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
const order: string[] = [];
router.registerRoute({
pattern: /\/a$/,
strategy: async () => {
order.push("first");
return new Response("first");
},
});
router.registerRoute({
pattern: /\/a$/,
strategy: async () => {
order.push("second");
return new Response("second");
},
});
const response = await router.handle(new Request("https://x.test/a"));
expect(await response.text()).toBe("first");
expect(order).toEqual(["first"]);
});
it("supports substring patterns", { timeout: 5000 }, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
router.registerRoute({
pattern: "/static/",
strategy: networkFirst(),
});
const response = await router.handle(
new Request("https://x.test/static/a.png")
);
expect(response.status).toBe(200);
});
it("supports predicate patterns", { timeout: 5000 }, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
router.registerRoute({
pattern: (req) => req.url.endsWith(".woff2"),
strategy: cacheFirst(),
});
const response = await router.handle(
new Request("https://x.test/font.woff2")
);
expect(response.status).toBe(200);
});
it("honors per-route cacheName override", { timeout: 5000 }, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
cacheName: "default",
clock: { now: () => 0 },
});
router.registerRoute({
pattern: /img\//,
strategy: cacheFirst(),
cacheName: "images",
});
await router.handle(new Request("https://x.test/img/a.png"));
expect(cacheStorage.caches.has("images")).toBe(true);
expect(cacheStorage.caches.has("default")).toBe(false);
});
it("size reports registered route count", { timeout: 5000 }, () => {
const router = new Router({
cacheStorage: new FakeCacheStorage() as unknown as CacheStorage,
fetcher: (() =>
Promise.resolve(new Response())) as unknown as typeof fetch,
clock: { now: () => 0 },
});
expect(router.size).toBe(0);
router.registerRoute({
pattern: /a/,
strategy: async () => new Response(),
});
expect(router.size).toBe(1);
});
it("skips routes whose method does not match the request", {
timeout: 5000,
}, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher, calls } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
// POST-scoped route; the inbound request is a GET → method mismatch
// exercises the `request.method.toUpperCase() !== method` continue branch.
router.registerRoute({
method: "POST",
pattern: /\/api\//,
strategy: async () => new Response("strategy-result"),
});
const response = await router.handle(
new Request("https://x.test/api/x")
);
// No route matched → falls through to fetcher.
expect(await response.text()).toBe("ok");
expect(calls.length).toBe(1);
});
it("constructs with no options at all, deriving every default", {
timeout: 5000,
}, () => {
// Drives the option-undefined branches in the constructor:
// cacheStorage ← globalThis.caches, fetcher ← globalThis.fetch,
// cacheName ← DEFAULT_CACHE_NAME, clock ← SYSTEM_CLOCK,
// observability ← createPwaObservability(undefined).
const router = new Router();
expect(router.size).toBe(0);
});
it("threads an injected observability bundle through the constructor", {
timeout: 5000,
}, async () => {
const observability = createPwaObservability();
const cacheStorage = new FakeCacheStorage();
const { fetcher } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
observability,
});
router.registerRoute({
pattern: /\/o/,
strategy: cacheFirst(),
});
const response = await router.handle(new Request("https://x.test/o"));
expect(response.status).toBe(200);
});
it("falls through when method matches but the pattern does not", {
timeout: 5000,
}, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher, calls } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
router.registerRoute({
method: "GET",
pattern: /\/never-match/,
strategy: async () => new Response("never"),
});
await router.handle(new Request("https://x.test/different"));
// No route matched → falls through to fetcher.
expect(calls.length).toBe(1);
});
it("falls through when the request method differs in case from the route method", {
timeout: 5000,
}, async () => {
const cacheStorage = new FakeCacheStorage();
const { fetcher, calls } = makeFetcher();
const router = new Router({
cacheStorage: cacheStorage as unknown as CacheStorage,
fetcher,
clock: { now: () => 0 },
});
// Lower-case method on the route exercises route.method.toUpperCase().
router.registerRoute({
method: "post",
pattern: /\/c/,
strategy: async () => new Response("strat"),
});
const response = await router.handle(
new Request("https://x.test/c", { method: "POST" })
);
expect(await response.text()).toBe("strat");
expect(calls.length).toBe(0);
});
it("falls through unconditionally when cacheStorage is absent", {
timeout: 5000,
}, async () => {
const { fetcher, calls } = makeFetcher();
const router = new Router({
cacheStorage: undefined,
fetcher,
clock: { now: () => 0 },
});
router.registerRoute({
pattern: /\/x/,
strategy: async () => new Response("via-strategy"),
});
const response = await router.handle(new Request("https://x.test/x"));
expect(await response.text()).toBe("ok");
expect(calls.length).toBe(1);
});
});

View File

@@ -0,0 +1,120 @@
import { createPwaObservability } from "../observability/observability";
import type { PwaObservability } from "../observability/observability.types";
import { type Clock, SYSTEM_CLOCK } from "../types/common";
import type { Strategy, StrategyContext } from "./caching-strategies.types";
/** Per-route matching shape. */
export interface Route {
/** HTTP method (uppercase). Defaults to `"GET"`. */
method?: string;
/** Pattern: RegExp, string (substring), or predicate function. */
pattern: RegExp | string | ((request: Request) => boolean);
/** Strategy handler. */
strategy: Strategy;
/** Optional override cache name. Defaults to the router's `cacheName`. */
cacheName?: string;
}
/** Options consumed by {@link Router}. */
export interface RouterOptions {
/** `CacheStorage` seam. Defaults to `globalThis.caches`. */
cacheStorage?: CacheStorage;
/** Default cache name. Defaults to `"suaveplan-pwa-v1"`. */
cacheName?: string;
/** Fetcher seam. Defaults to `globalThis.fetch`. */
fetcher?: typeof fetch;
/** Clock seam. Defaults to `Date.now`. */
clock?: Clock;
/** Optional observability bundle. */
observability?: PwaObservability;
}
const DEFAULT_CACHE_NAME = "suaveplan-pwa-v1";
/**
* Maps incoming `Request`s to strategies. Iterates routes in registration
* order — the first match wins. Unmatched requests fall through to the
* default fetcher.
*/
export class Router {
private readonly _routes: Route[] = [];
private readonly _cacheStorage: CacheStorage | undefined;
private readonly _defaultCacheName: string;
private readonly _fetcher: typeof fetch;
private readonly _clock: Clock;
private readonly _observability: PwaObservability;
public constructor(options: RouterOptions = {}) {
this._cacheStorage =
options.cacheStorage ??
(globalThis as { caches?: CacheStorage }).caches;
this._defaultCacheName = options.cacheName ?? DEFAULT_CACHE_NAME;
this._fetcher =
options.fetcher ??
((globalThis as { fetch?: typeof fetch }).fetch as typeof fetch);
this._clock = options.clock ?? SYSTEM_CLOCK;
this._observability = createPwaObservability(
options.observability !== undefined
? { observability: options.observability }
: undefined
);
}
/** Append a route. Order of registration is order of evaluation. */
public registerRoute(route: Route): void {
this._routes.push(route);
}
/** Number of registered routes. */
public get size(): number {
return this._routes.length;
}
/**
* Resolve `request` against the registered routes. Returns the strategy's
* response. Unmatched requests fall through to `fetcher(request)`.
*/
public async handle(request: Request): Promise<Response> {
const route = this._match(request);
if (route === null) {
return await this._fetcher(request);
}
if (this._cacheStorage === undefined) {
return await this._fetcher(request);
}
const cache = await this._cacheStorage.open(
route.cacheName ?? this._defaultCacheName
);
const context: StrategyContext = {
cache,
fetcher: this._fetcher,
clock: this._clock,
};
const startedAt = this._clock.now();
try {
return await route.strategy(request, context);
} finally {
this._observability.histograms.cacheStrategyMs.record(
this._clock.now() - startedAt
);
}
}
private _match(request: Request): Route | null {
for (const route of this._routes) {
const method = (route.method ?? "GET").toUpperCase();
if (request.method.toUpperCase() !== method) continue;
if (this._patternMatches(route.pattern, request)) return route;
}
return null;
}
private _patternMatches(
pattern: Route["pattern"],
request: Request
): boolean {
if (typeof pattern === "string") return request.url.includes(pattern);
if (pattern instanceof RegExp) return pattern.test(request.url);
return pattern(request);
}
}

View File

@@ -0,0 +1,32 @@
/** Default service-worker update polling interval (1 hour). */
export const DEFAULT_UPDATE_INTERVAL = 60 * 60 * 1000;
/** Default maximum sync-queue size. */
export const DEFAULT_MAX_QUEUE_SIZE = 100;
/** Default retry cap before an entry is treated as permanently failed. */
export const DEFAULT_MAX_RETRIES = 3;
/** Default retry delay in milliseconds. */
export const DEFAULT_RETRY_DELAY = 5000;
/** Default exponential-backoff factor applied to retry delay. */
export const DEFAULT_RETRY_BACKOFF_FACTOR = 2;
/** Storage / IDB key under which the sync queue is persisted. */
export const SYNC_QUEUE_STORAGE_KEY = "__pwa_sync_queue__";
/** Storage key under which the install-prompt dismissal flag is persisted. */
export const INSTALL_PROMPT_KEY = "__pwa_install_dismissed__";
/** Default BroadcastChannel name for cross-tab update coordination. */
export const UPDATE_CHANNEL_NAME = "@suaveplan/pwa/update";
/** Default IndexedDB database name. */
export const DEFAULT_IDB_DATABASE = "__suaveplan_pwa__";
/** Default IndexedDB object-store name for the sync queue. */
export const DEFAULT_IDB_OBJECT_STORE = "sync_queue";
/** Current sync-queue schema version. */
export const SYNC_QUEUE_SCHEMA_VERSION = 2;

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PWA_CODES,
PWA_ERROR_METADATA,
PwaError,
type PwaErrorCode,
} from "./errors";
const ALL_CODES = Object.values(PWA_CODES) as PwaErrorCode[];
describe("PWA error registry", () => {
it("every code has metadata and is reachable from a documented call site", {
timeout: 5000,
}, () => {
const exceptedReasons = [
"SW_UNSUPPORTED",
"SW_REGISTRATION_FAILED",
"SW_UPDATE_FAILED",
"PUSH_UNSUPPORTED",
"PUSH_PERMISSION_DENIED",
"SYNC_QUEUE_FULL",
"SYNC_QUEUE_VALIDATION",
"MANIFEST_VALIDATION",
];
expect(Object.keys(PWA_CODES).sort()).toEqual(
[...exceptedReasons].sort()
);
for (const code of ALL_CODES) {
const meta = PWA_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(meta.httpStatus).toBeGreaterThanOrEqual(400);
expect(meta.description.length).toBeGreaterThan(20);
expect(["info", "warning", "error"]).toContain(meta.severity);
expect(typeof meta.retryable).toBe("boolean");
const err = new PwaError(code, `triggered for ${code}`);
expect(err).toBeInstanceOf(PwaError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("PwaError");
expect(err.code).toBe(code);
}
});
it("preserves cause chain", { timeout: 5000 }, () => {
const cause = new TypeError("network down");
const err = new PwaError(
PWA_CODES.SW_REGISTRATION_FAILED,
"registration failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("outer metadata table is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(PWA_ERROR_METADATA)).toBe(true);
expect(() => {
(
PWA_ERROR_METADATA as unknown as Record<string, unknown>
).PWA_UNKNOWN = { httpStatus: 0 };
}).toThrow();
});
});

View File

@@ -0,0 +1,109 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes emitted by `@suaveplan/pwa`. Every error raised by the
* package extends {@link PwaError} and carries one of these codes so consumers
* (loggers, retry policies, observability sinks) can classify failures without
* string-matching on `message`.
*/
export const PWA_CODES = defineCodes("PWA", [
"SW_UNSUPPORTED",
"SW_REGISTRATION_FAILED",
"SW_UPDATE_FAILED",
"PUSH_UNSUPPORTED",
"PUSH_PERMISSION_DENIED",
"SYNC_QUEUE_FULL",
"SYNC_QUEUE_VALIDATION",
"MANIFEST_VALIDATION",
] as const);
/** Union of every error code emitted by `@suaveplan/pwa`. */
export type PwaErrorCode = (typeof PWA_CODES)[keyof typeof PWA_CODES];
/** Per-code metadata for HTTP / retry classification. */
export interface PwaCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link PwaErrorCode} to its metadata. */
export const PWA_ERROR_METADATA: Readonly<
Record<PwaErrorCode, PwaCodeMetadata>
> = Object.freeze({
[PWA_CODES.SW_UNSUPPORTED]: {
httpStatus: 501,
severity: "warning",
retryable: false,
description:
"Service workers are not supported in the current runtime (navigator.serviceWorker missing).",
},
[PWA_CODES.SW_REGISTRATION_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: true,
description:
"Service worker registration failed; navigator.serviceWorker.register rejected.",
},
[PWA_CODES.SW_UPDATE_FAILED]: {
httpStatus: 500,
severity: "warning",
retryable: true,
description:
"Service worker update check failed; ServiceWorkerRegistration.update rejected.",
},
[PWA_CODES.PUSH_UNSUPPORTED]: {
httpStatus: 501,
severity: "warning",
retryable: false,
description:
"Push notifications are not supported (PushManager or Notification missing).",
},
[PWA_CODES.PUSH_PERMISSION_DENIED]: {
httpStatus: 403,
severity: "warning",
retryable: false,
description:
"User denied notification permission; push subscription cannot proceed.",
},
[PWA_CODES.SYNC_QUEUE_FULL]: {
httpStatus: 507,
severity: "warning",
retryable: false,
description:
"Sync queue is at capacity; enqueue rejected. Drain via process() or increase maxSize.",
},
[PWA_CODES.SYNC_QUEUE_VALIDATION]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"Sync queue entry failed Zod boundary validation (SyncQueueEntrySchema).",
},
[PWA_CODES.MANIFEST_VALIDATION]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"Web App Manifest failed Zod boundary validation (WebAppManifestSchema).",
},
});
/**
* Base error for all `@suaveplan/pwa` failures. Extends `SuaveplanError` so
* logger / OTel / wire serialisers integrate without per-package glue code.
*/
export class PwaError<
TCode extends PwaErrorCode = PwaErrorCode,
> extends SuaveplanError<TCode> {
public constructor(
code: TCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "PwaError";
}
}

View File

@@ -0,0 +1,157 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as CachingStrategiesModule from "./caching-strategies/caching-strategies";
import * as RouterModule from "./caching-strategies/router";
// Subpath module imports — typecheck-only assertion that each ships an export.
import * as ErrorsModule from "./errors/errors";
import * as Pwa from "./index";
import * as InstallPromptModule from "./install-prompt/install-prompt";
import * as ManifestModule from "./manifest/manifest";
import * as ObservabilityModule from "./observability/observability";
import * as PushModule from "./push-notifications/push-notifications";
import * as VapidModule from "./push-notifications/vapid";
import * as SchemasModule from "./schemas/schemas";
import * as SwModule from "./service-worker/service-worker";
import * as BackgroundSyncModule from "./sync-queue/background-sync";
import * as IndexedDbStoreModule from "./sync-queue/indexeddb-store";
import * as LocalStorageStoreModule from "./sync-queue/local-storage-store";
import * as MemoryStoreModule from "./sync-queue/memory-store";
import * as SyncQueueModule from "./sync-queue/sync-queue";
import * as VersionMigrationModule from "./sync-queue/version-migration";
import * as UpdateModule from "./update-controller/update-controller";
const SRC_DIR = dirname(fileURLToPath(import.meta.url));
function walkSrc(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walkSrc(full, out);
else if (entry.endsWith(".ts") || entry.endsWith(".tsx"))
out.push(full);
}
return out;
}
describe("package surface", () => {
it("every documented subpath resolves to its module", {
timeout: 5000,
}, () => {
expect(ErrorsModule.PwaError).toBeDefined();
expect(SchemasModule.WebAppManifestSchema).toBeDefined();
expect(ObservabilityModule.createPwaObservability).toBeDefined();
expect(SwModule.ServiceWorkerManager).toBeDefined();
expect(UpdateModule.UpdateController).toBeDefined();
expect(SyncQueueModule.SyncQueue).toBeDefined();
expect(MemoryStoreModule.MemoryStore).toBeDefined();
expect(LocalStorageStoreModule.LocalStorageStore).toBeDefined();
expect(IndexedDbStoreModule.IndexedDbStore).toBeDefined();
expect(VersionMigrationModule.migrateEntry).toBeDefined();
expect(BackgroundSyncModule.BackgroundSyncBridge).toBeDefined();
expect(PushModule.PushNotificationManager).toBeDefined();
expect(VapidModule.urlBase64ToUint8Array).toBeDefined();
expect(InstallPromptModule.InstallPromptManager).toBeDefined();
expect(CachingStrategiesModule.cacheFirst).toBeDefined();
expect(RouterModule.Router).toBeDefined();
expect(ManifestModule.ManifestBuilder).toBeDefined();
});
it("barrel re-exports all public symbols", { timeout: 5000 }, () => {
const required = [
"PwaError",
"PWA_CODES",
"PWA_ERROR_METADATA",
"createPwaObservability",
"ServiceWorkerManager",
"UpdateController",
"SyncQueue",
"MemoryStore",
"LocalStorageStore",
"IndexedDbStore",
"BackgroundSyncBridge",
"PushNotificationManager",
"urlBase64ToUint8Array",
"InstallPromptManager",
"ManifestBuilder",
"Router",
"cacheFirst",
"networkFirst",
"staleWhileRevalidate",
"cacheOnly",
"networkOnly",
"SyncQueueEntrySchema",
"WebAppManifestSchema",
"JsonValueSchema",
"migrateEntry",
"migrateBatch",
"DEFAULT_MIGRATORS",
"asSyncQueueEntry",
"DEFAULT_MAX_QUEUE_SIZE",
"DEFAULT_MAX_RETRIES",
"DEFAULT_RETRY_DELAY",
"DEFAULT_RETRY_BACKOFF_FACTOR",
"DEFAULT_UPDATE_INTERVAL",
"SYNC_QUEUE_STORAGE_KEY",
"INSTALL_PROMPT_KEY",
"UPDATE_CHANNEL_NAME",
"SYNC_QUEUE_SCHEMA_VERSION",
"DEFAULT_IDB_DATABASE",
"DEFAULT_IDB_OBJECT_STORE",
"SYSTEM_CLOCK",
];
for (const name of required) {
expect((Pwa as Record<string, unknown>)[name]).toBeDefined();
}
});
it("src/ contains no Node-only globals", { timeout: 5000 }, () => {
const files = walkSrc(SRC_DIR).filter((p) => !p.includes(".test."));
for (const path of files) {
const text = readFileSync(path, "utf8");
expect(
/\bfrom\s+["']node:/.test(text),
`${path} imports node:`
).toBe(false);
expect(
/\bprocess\.env\b/.test(text),
`${path} references process.env`
).toBe(false);
expect(/\bBuffer\.from\b/.test(text), `${path} uses Buffer`).toBe(
false
);
}
});
it("no banned patterns in src/", { timeout: 5000 }, () => {
// Self-exclude — this file enumerates the banned patterns as data.
const files = walkSrc(SRC_DIR).filter(
(p) => !p.endsWith("/index.test.ts")
);
const banned: { pattern: RegExp; label: string }[] = [
{ pattern: /vi\.stubGlobal/, label: "vi.stubGlobal" },
{ pattern: /vi\.mock\(/, label: "vi.mock" },
{ pattern: /\/\/\s*@ts-ignore/, label: "@ts-ignore" },
{
pattern: /\/\/\s*@ts-expect-error/,
label: "@ts-expect-error",
},
{ pattern: /\bTODO\b/, label: "TODO" },
{ pattern: /\bFIXME\b/, label: "FIXME" },
{ pattern: /\.skip\(/, label: ".skip(" },
{ pattern: /\.only\(/, label: ".only(" },
{ pattern: /console\.log\(/, label: "console.log" },
{ pattern: /\bbiome-ignore\b/, label: "biome-ignore" },
];
for (const path of files) {
const text = readFileSync(path, "utf8");
for (const b of banned) {
expect(
b.pattern.test(text),
`${path} contains ${b.label}`
).toBe(false);
}
}
});
});

View File

@@ -0,0 +1,144 @@
// Constants
// Caching Strategies
export {
cacheFirst,
cacheOnly,
networkFirst,
networkOnly,
staleWhileRevalidate,
} from "./caching-strategies/caching-strategies";
export type {
Strategy,
StrategyContext,
StrategyOptions,
} from "./caching-strategies/caching-strategies.types";
export {
type Route,
Router,
type RouterOptions,
} from "./caching-strategies/router";
export {
DEFAULT_IDB_DATABASE,
DEFAULT_IDB_OBJECT_STORE,
DEFAULT_MAX_QUEUE_SIZE,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_FACTOR,
DEFAULT_RETRY_DELAY,
DEFAULT_UPDATE_INTERVAL,
INSTALL_PROMPT_KEY,
SYNC_QUEUE_SCHEMA_VERSION,
SYNC_QUEUE_STORAGE_KEY,
UPDATE_CHANNEL_NAME,
} from "./constants/index";
// Errors
export {
PWA_CODES,
PWA_ERROR_METADATA,
type PwaCodeMetadata,
PwaError,
type PwaErrorCode,
} from "./errors/errors";
// Install Prompt
export { InstallPromptManager } from "./install-prompt/install-prompt";
export type {
IInstallPromptManager,
InstallPromptManagerOptions,
} from "./install-prompt/install-prompt.types";
// Manifest
export { ManifestBuilder } from "./manifest/manifest";
// Observability
export { createPwaObservability } from "./observability/observability";
export type {
PwaCounters,
PwaHistograms,
PwaObservability,
PwaObservabilityOptions,
} from "./observability/observability.types";
// Push Notifications
export { PushNotificationManager } from "./push-notifications/push-notifications";
export type {
IPushNotificationManager,
PushNotificationManagerOptions,
} from "./push-notifications/push-notifications.types";
export { urlBase64ToUint8Array } from "./push-notifications/vapid";
// Schemas
export {
JsonValueSchema,
type ManifestIcon,
ManifestIconSchema,
type ManifestShortcut,
ManifestShortcutSchema,
type RelatedApplication,
RelatedApplicationSchema,
type ShareTarget,
ShareTargetSchema,
SyncQueueEntrySchema,
type WebAppManifest,
WebAppManifestSchema,
} from "./schemas/schemas";
// Service Worker
export { ServiceWorkerManager } from "./service-worker/service-worker";
export type {
IntervalScheduler,
IServiceWorkerManager,
ServiceWorkerManagerOptions,
} from "./service-worker/service-worker.types";
export {
BackgroundSyncBridge,
type BackgroundSyncBridgeOptions,
type SyncCapableRegistration,
type SyncManagerLike,
type VisibilityDocumentLike,
} from "./sync-queue/background-sync";
export {
IndexedDbStore,
type IndexedDbStoreOptions,
} from "./sync-queue/indexeddb-store";
export {
LocalStorageStore,
type LocalStorageStoreOptions,
} from "./sync-queue/local-storage-store";
export { MemoryStore } from "./sync-queue/memory-store";
// Sync Queue
export { SyncQueue } from "./sync-queue/sync-queue";
export type {
IdGenerator,
ISyncQueue,
SyncQueueOptions,
SyncQueueStore,
} from "./sync-queue/sync-queue.types";
export {
asSyncQueueEntry,
DEFAULT_MIGRATORS,
migrateBatch,
migrateEntry,
type VersionMigrator,
} from "./sync-queue/version-migration";
// Common types
export {
type Clock,
type InstallPromptListener,
type InstallPromptState,
type NotificationStaticLike,
type PushNotificationOptions,
type PushPermission,
type PushSubscriptionResult,
type StorageLike,
type SwRegistrationOptions,
type SwRegistrationResult,
type SwState,
SYSTEM_CLOCK,
type SyncQueueConfig,
type SyncQueueEntry,
type WindowLike,
} from "./types/common";
// Update Controller
export {
type BroadcastChannelFactory,
type BroadcastChannelLike,
UpdateController,
type UpdateControllerOptions,
type UpdateMessage,
type UpdateWindowLike,
} from "./update-controller/update-controller";

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,302 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { StorageLike, WindowLike } from "../types/common";
import { InstallPromptManager } from "./install-prompt";
interface BIPEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
platforms: string[];
}
class FakeWindow implements WindowLike {
private listeners = new Map<string, ((event: Event) => void)[]>();
public addEventListener(
type: string,
listener: (event: Event) => void
): void {
const arr = this.listeners.get(type) ?? [];
arr.push(listener);
this.listeners.set(type, arr);
}
public removeEventListener(
type: string,
listener: (event: Event) => void
): void {
const arr = (this.listeners.get(type) ?? []).filter(
(l) => l !== listener
);
this.listeners.set(type, arr);
}
public dispatch(type: string, event: Event): void {
for (const l of [...(this.listeners.get(type) ?? [])]) l(event);
}
public listenerCount(type: string): number {
return (this.listeners.get(type) ?? []).length;
}
}
function makeStorage(): StorageLike {
const data = new Map<string, string>();
return {
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => {
data.set(k, v);
},
removeItem: (k) => {
data.delete(k);
},
};
}
function makeBIPEvent(outcome: "accepted" | "dismissed"): BIPEvent {
const event = new Event("beforeinstallprompt") as BIPEvent;
event.platforms = ["web"];
event.userChoice = Promise.resolve({ outcome });
event.prompt = async () => {};
return event;
}
describe("InstallPromptManager", () => {
it("captures beforeinstallprompt and exposes available", {
timeout: 5000,
}, () => {
const window = new FakeWindow();
const storage = makeStorage();
const ipm = new InstallPromptManager({ window, storage });
ipm.listen();
const states: string[] = [];
ipm.onStateChange((s) => states.push(s));
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
expect(ipm.isAvailable).toBe(true);
expect(states).toEqual(["available"]);
ipm.unlisten();
expect(window.listenerCount("beforeinstallprompt")).toBe(0);
});
it("prompt() resolves accepted and updates state", {
timeout: 5000,
}, async () => {
const window = new FakeWindow();
const ipm = new InstallPromptManager({
window,
storage: makeStorage(),
});
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
expect(await ipm.prompt()).toBe("accepted");
expect(ipm.state).toBe("accepted");
});
it("persists dismissal across instances", { timeout: 5000 }, async () => {
const window = new FakeWindow();
const storage = makeStorage();
const ipm = new InstallPromptManager({ window, storage });
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("dismissed"));
expect(await ipm.prompt()).toBe("dismissed");
const ipm2 = new InstallPromptManager({
window: new FakeWindow(),
storage,
});
expect(ipm2.wasDismissed).toBe(true);
ipm2.clearDismissed();
expect(ipm2.wasDismissed).toBe(false);
});
it("dismiss() clears the deferred prompt and persists", {
timeout: 5000,
}, () => {
const window = new FakeWindow();
const storage = makeStorage();
const ipm = new InstallPromptManager({ window, storage });
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
ipm.dismiss();
expect(ipm.state).toBe("dismissed");
expect(storage.getItem("__pwa_install_dismissed__")).toBe("true");
});
it("appinstalled transitions to accepted", { timeout: 5000 }, () => {
const window = new FakeWindow();
const ipm = new InstallPromptManager({
window,
storage: makeStorage(),
});
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
window.dispatch("appinstalled", new Event("appinstalled"));
expect(ipm.state).toBe("accepted");
});
it("prompt() with no deferred event returns current state", {
timeout: 5000,
}, async () => {
const ipm = new InstallPromptManager({
window: new FakeWindow(),
storage: makeStorage(),
});
expect(await ipm.prompt()).toBe("idle");
});
it("listen() is a no-op when no window seam is available", {
timeout: 5000,
}, () => {
const ipm = new InstallPromptManager({
window: undefined,
storage: makeStorage(),
});
expect(() => ipm.listen()).not.toThrow();
expect(() => ipm.unlisten()).not.toThrow();
});
it("reset() returns to idle and unlistens", { timeout: 5000 }, () => {
const window = new FakeWindow();
const ipm = new InstallPromptManager({
window,
storage: makeStorage(),
});
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
ipm.reset();
expect(ipm.state).toBe("idle");
expect(window.listenerCount("beforeinstallprompt")).toBe(0);
});
it("onStateChange unsubscribe stops further notifications", {
timeout: 5000,
}, () => {
const window = new FakeWindow();
const ipm = new InstallPromptManager({
window,
storage: makeStorage(),
});
const states: string[] = [];
const off = ipm.onStateChange((s) => states.push(s));
ipm.listen();
window.dispatch("beforeinstallprompt", makeBIPEvent("accepted"));
off();
ipm.dismiss();
expect(states).toEqual(["available"]);
});
it("unlisten() is a no-op when listen() was never called", {
timeout: 5000,
}, () => {
const ipm = new InstallPromptManager({
window: new FakeWindow(),
storage: makeStorage(),
});
// No listen() → _boundHandler / _boundAppInstalled stay null.
expect(() => ipm.unlisten()).not.toThrow();
});
it("onStateChange unsubscribe is idempotent", { timeout: 5000 }, () => {
const ipm = new InstallPromptManager({
window: new FakeWindow(),
storage: makeStorage(),
});
const off = ipm.onStateChange(() => {});
off();
// Second invocation: indexOf returns -1, splice is skipped.
expect(() => off()).not.toThrow();
});
it("threads an injected observability bundle through the constructor", {
timeout: 5000,
}, async () => {
const { createPwaObservability } = await import(
"../observability/observability"
);
const observability = createPwaObservability();
const ipm = new InstallPromptManager({
window: new FakeWindow(),
storage: makeStorage(),
observability,
});
expect(ipm.state).toBe("idle");
});
it("derives window + storage from globals when no options are passed", {
timeout: 5000,
}, () => {
// happy-dom provides globalThis.addEventListener + localStorage, so the
// bare constructor exercises the truthy globalThis-fallback branches
// for both the window seam and the storage seam.
const ipm = new InstallPromptManager();
expect(ipm.state).toBe("idle");
expect(() => ipm.listen()).not.toThrow();
ipm.reset();
});
it("resolves both seams to undefined fallback when globals are stripped", {
timeout: 5000,
}, () => {
const originalLocal = (globalThis as { localStorage?: StorageLike })
.localStorage;
const originalAdd = (globalThis as { addEventListener?: unknown })
.addEventListener;
// happy-dom installs addEventListener on globalThis — strip it so the
// window-detection falls to undefined and exercises the falsy branch.
Reflect.deleteProperty(globalThis as object, "addEventListener");
(globalThis as { localStorage?: StorageLike }).localStorage = undefined;
try {
const ipm = new InstallPromptManager();
// listen() is a no-op when window is undefined.
expect(() => ipm.listen()).not.toThrow();
expect(() => ipm.unlisten()).not.toThrow();
// wasDismissed routes through NULL_STORAGE.getItem returning null.
expect(ipm.wasDismissed).toBe(false);
} finally {
if (originalAdd !== undefined) {
(
globalThis as { addEventListener?: unknown }
).addEventListener = originalAdd;
}
(globalThis as { localStorage?: StorageLike }).localStorage =
originalLocal;
}
});
it("falls back to NULL_STORAGE when no storage is injected and globalThis.localStorage is absent", {
timeout: 5000,
}, () => {
const original = (globalThis as { localStorage?: StorageLike })
.localStorage;
(globalThis as { localStorage?: StorageLike }).localStorage = undefined;
try {
const ipm = new InstallPromptManager({
window: new FakeWindow(),
});
// Drives the NULL_STORAGE.getItem path — returns null → wasDismissed false.
expect(ipm.wasDismissed).toBe(false);
// Drives the NULL_STORAGE.setItem path — no-op, must not throw.
expect(() => ipm.dismiss()).not.toThrow();
// Drives the NULL_STORAGE.removeItem path.
expect(() => ipm.clearDismissed()).not.toThrow();
} finally {
(globalThis as { localStorage?: StorageLike }).localStorage =
original;
}
});
it("wasDismissed tolerates storage throwing", { timeout: 5000 }, () => {
const throwing: StorageLike = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
removeItem: () => {
throw new Error("blocked");
},
};
const ipm = new InstallPromptManager({
window: new FakeWindow(),
storage: throwing,
});
expect(ipm.wasDismissed).toBe(false);
expect(() => ipm.dismiss()).not.toThrow();
expect(() => ipm.clearDismissed()).not.toThrow();
});
});

View File

@@ -0,0 +1,183 @@
import { INSTALL_PROMPT_KEY } from "../constants/index";
import { createPwaObservability } from "../observability/observability";
import type { PwaObservability } from "../observability/observability.types";
import type {
InstallPromptListener,
InstallPromptState,
StorageLike,
WindowLike,
} from "../types/common";
import type {
IInstallPromptManager,
InstallPromptManagerOptions,
} from "./install-prompt.types";
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
prompt(): Promise<void>;
}
const NULL_STORAGE: StorageLike = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
/**
* Manages the PWA install-prompt lifecycle. Captures the
* `beforeinstallprompt` event, exposes a programmatic `prompt()`, persists
* dismissal across sessions, and emits OTel `install-prompt` counters
* partitioned by outcome.
*/
export class InstallPromptManager implements IInstallPromptManager {
private _deferredPrompt: BeforeInstallPromptEvent | null = null;
private _state: InstallPromptState = "idle";
private readonly _listeners: InstallPromptListener[] = [];
private _boundHandler: ((event: Event) => void) | null = null;
private _boundAppInstalled: (() => void) | null = null;
private readonly _window: WindowLike | undefined;
private readonly _storage: StorageLike;
private readonly _observability: PwaObservability;
public constructor(options: InstallPromptManagerOptions = {}) {
this._window =
options.window ??
(typeof globalThis !== "undefined" &&
"addEventListener" in (globalThis as { addEventListener?: unknown })
? (globalThis as unknown as WindowLike)
: undefined);
this._storage =
options.storage ??
((globalThis as { localStorage?: StorageLike }).localStorage as
| StorageLike
| undefined) ??
NULL_STORAGE;
this._observability = createPwaObservability(
options.observability !== undefined
? { observability: options.observability }
: undefined
);
}
public get state(): InstallPromptState {
return this._state;
}
public get isAvailable(): boolean {
return this._state === "available";
}
public get wasDismissed(): boolean {
try {
return this._storage.getItem(INSTALL_PROMPT_KEY) === "true";
} catch {
return false;
}
}
public listen(): void {
if (this._window === undefined) return;
this._boundHandler = (event: Event) => {
event.preventDefault();
this._deferredPrompt = event as BeforeInstallPromptEvent;
this._setState("available");
};
this._boundAppInstalled = () => {
this._setState("accepted");
this._deferredPrompt = null;
};
this._window.addEventListener(
"beforeinstallprompt",
this._boundHandler
);
this._window.addEventListener(
"appinstalled",
this._boundAppInstalled as (event: Event) => void
);
}
public unlisten(): void {
if (this._window === undefined) return;
if (this._boundHandler !== null) {
this._window.removeEventListener(
"beforeinstallprompt",
this._boundHandler
);
this._boundHandler = null;
}
if (this._boundAppInstalled !== null) {
this._window.removeEventListener(
"appinstalled",
this._boundAppInstalled as (event: Event) => void
);
this._boundAppInstalled = null;
}
}
public async prompt(): Promise<InstallPromptState> {
if (this._deferredPrompt === null) return this._state;
await this._deferredPrompt.prompt();
const { outcome } = await this._deferredPrompt.userChoice;
if (outcome === "accepted") {
this._setState("accepted");
this._observability.counters.installPromptTotal.add(1, {
outcome: "accepted",
});
} else {
this._setState("dismissed");
this._persistDismiss();
this._observability.counters.installPromptTotal.add(1, {
outcome: "dismissed",
});
}
this._deferredPrompt = null;
return this._state;
}
public dismiss(): void {
this._deferredPrompt = null;
this._setState("dismissed");
this._persistDismiss();
this._observability.counters.installPromptTotal.add(1, {
outcome: "dismissed",
});
}
public clearDismissed(): void {
try {
this._storage.removeItem(INSTALL_PROMPT_KEY);
} catch {
// storage unavailable
}
}
public onStateChange(listener: InstallPromptListener): () => void {
this._listeners.push(listener);
return () => {
const idx = this._listeners.indexOf(listener);
if (idx >= 0) this._listeners.splice(idx, 1);
};
}
public reset(): void {
this.unlisten();
this._deferredPrompt = null;
this._setState("idle");
}
private _setState(state: InstallPromptState): void {
this._state = state;
for (const listener of this._listeners) {
listener(state);
}
}
private _persistDismiss(): void {
try {
this._storage.setItem(INSTALL_PROMPT_KEY, "true");
} catch {
// storage unavailable
}
}
}

View File

@@ -0,0 +1,31 @@
import type { PwaObservability } from "../observability/observability.types";
import type {
InstallPromptListener,
InstallPromptState,
StorageLike,
WindowLike,
} from "../types/common";
/** Options consumed by `InstallPromptManager`. */
export interface InstallPromptManagerOptions {
/** Event-target seam, defaults to `globalThis.window`. */
window?: WindowLike;
/** Storage seam, defaults to `globalThis.localStorage`. */
storage?: StorageLike;
/** Optional observability bundle. */
observability?: PwaObservability;
}
/** Public surface of `InstallPromptManager`. */
export interface IInstallPromptManager {
readonly state: InstallPromptState;
readonly isAvailable: boolean;
readonly wasDismissed: boolean;
listen(): void;
unlisten(): void;
prompt(): Promise<InstallPromptState>;
dismiss(): void;
clearDismissed(): void;
onStateChange(listener: InstallPromptListener): () => void;
reset(): void;
}

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,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PWA_CODES, PwaError } from "../errors/errors";
import { ManifestBuilder } from "./manifest";
describe("ManifestBuilder", () => {
it("builds a valid minimal manifest", { timeout: 5000 }, () => {
const m = new ManifestBuilder()
.setName("App")
.addIcon({ src: "/i.png", sizes: "1x1" })
.build();
expect(m.name).toBe("App");
expect(m.icons.length).toBe(1);
});
it("raises MANIFEST_VALIDATION when required fields are missing", {
timeout: 5000,
}, () => {
try {
new ManifestBuilder().build();
throw new Error("did not throw");
} catch (e) {
expect(e).toBeInstanceOf(PwaError);
expect((e as PwaError).code).toBe(PWA_CODES.MANIFEST_VALIDATION);
}
});
it("raises MANIFEST_VALIDATION when no icons are added", {
timeout: 5000,
}, () => {
try {
new ManifestBuilder().setName("X").build();
throw new Error("did not throw");
} catch (e) {
expect((e as PwaError).code).toBe(PWA_CODES.MANIFEST_VALIDATION);
}
});
it("supports every documented setter", { timeout: 5000 }, () => {
const m = new ManifestBuilder()
.setName("X")
.setShortName("X")
.setDescription("desc")
.setStartUrl("/")
.setScope("/")
.setDisplay("standalone")
.setOrientation("portrait")
.setBackgroundColor("#fff")
.setThemeColor("#000")
.setLang("en")
.setDir("ltr")
.setPreferRelatedApplications(false)
.addIcon({ src: "/i.png", sizes: "1x1" })
.addShortcut({
name: "S",
url: "/s",
icons: [{ src: "/s.png", sizes: "1x1" }],
})
.addRelatedApplication({ platform: "ios", id: "app.id" })
.setShareTarget({
action: "/share",
method: "POST",
enctype: "multipart/form-data",
params: { title: "t", url: "u" },
})
.build();
expect(m.short_name).toBe("X");
expect(m.display).toBe("standalone");
expect(m.orientation).toBe("portrait");
expect(m.dir).toBe("ltr");
expect(m.shortcuts?.length).toBe(1);
expect(m.related_applications?.length).toBe(1);
expect(m.share_target?.action).toBe("/share");
expect(m.prefer_related_applications).toBe(false);
});
it("toBlob produces application/manifest+json", {
timeout: 5000,
}, async () => {
const blob = new ManifestBuilder()
.setName("App")
.addIcon({ src: "/i.png", sizes: "1x1" })
.toBlob();
expect(blob.type).toBe("application/manifest+json");
const text = await blob.text();
const parsed = JSON.parse(text) as { name: string };
expect(parsed.name).toBe("App");
});
});

View File

@@ -0,0 +1,152 @@
import { PWA_CODES, PwaError } from "../errors/errors";
import {
type ManifestIcon,
type ManifestShortcut,
type RelatedApplication,
type ShareTarget,
type WebAppManifest,
WebAppManifestSchema,
} from "../schemas/schemas";
type Display = NonNullable<WebAppManifest["display"]>;
type Orientation = NonNullable<WebAppManifest["orientation"]>;
type Dir = NonNullable<WebAppManifest["dir"]>;
/**
* Fluent builder for the Web App Manifest. Every setter returns `this` so
* calls chain naturally. `build()` validates the result through
* `WebAppManifestSchema` and raises `PwaError(MANIFEST_VALIDATION)` on
* failure. `toBlob()` serialises to an `application/manifest+json` Blob.
*/
export class ManifestBuilder {
private _name: string | undefined;
private _shortName: string | undefined;
private _description: string | undefined;
private _startUrl: string | undefined;
private _scope: string | undefined;
private _display: Display | undefined;
private _orientation: Orientation | undefined;
private _backgroundColor: string | undefined;
private _themeColor: string | undefined;
private _lang: string | undefined;
private _dir: Dir | undefined;
private readonly _icons: ManifestIcon[] = [];
private readonly _shortcuts: ManifestShortcut[] = [];
private readonly _related: RelatedApplication[] = [];
private _preferRelated: boolean | undefined;
private _shareTarget: ShareTarget | undefined;
public setName(name: string): this {
this._name = name;
return this;
}
public setShortName(name: string): this {
this._shortName = name;
return this;
}
public setDescription(d: string): this {
this._description = d;
return this;
}
public setStartUrl(url: string): this {
this._startUrl = url;
return this;
}
public setScope(scope: string): this {
this._scope = scope;
return this;
}
public setDisplay(display: Display): this {
this._display = display;
return this;
}
public setOrientation(orientation: Orientation): this {
this._orientation = orientation;
return this;
}
public setBackgroundColor(color: string): this {
this._backgroundColor = color;
return this;
}
public setThemeColor(color: string): this {
this._themeColor = color;
return this;
}
public setLang(lang: string): this {
this._lang = lang;
return this;
}
public setDir(dir: Dir): this {
this._dir = dir;
return this;
}
public addIcon(icon: ManifestIcon): this {
this._icons.push(icon);
return this;
}
public addShortcut(shortcut: ManifestShortcut): this {
this._shortcuts.push(shortcut);
return this;
}
public addRelatedApplication(app: RelatedApplication): this {
this._related.push(app);
return this;
}
public setPreferRelatedApplications(value: boolean): this {
this._preferRelated = value;
return this;
}
public setShareTarget(target: ShareTarget): this {
this._shareTarget = target;
return this;
}
/** Validate and return the manifest object. */
public build(): WebAppManifest {
const candidate: Record<string, unknown> = {
name: this._name,
icons: this._icons,
};
if (this._shortName !== undefined)
candidate["short_name"] = this._shortName;
if (this._description !== undefined)
candidate["description"] = this._description;
if (this._startUrl !== undefined)
candidate["start_url"] = this._startUrl;
if (this._scope !== undefined) candidate["scope"] = this._scope;
if (this._display !== undefined) candidate["display"] = this._display;
if (this._orientation !== undefined)
candidate["orientation"] = this._orientation;
if (this._backgroundColor !== undefined)
candidate["background_color"] = this._backgroundColor;
if (this._themeColor !== undefined)
candidate["theme_color"] = this._themeColor;
if (this._lang !== undefined) candidate["lang"] = this._lang;
if (this._dir !== undefined) candidate["dir"] = this._dir;
if (this._shortcuts.length > 0)
candidate["shortcuts"] = this._shortcuts;
if (this._related.length > 0)
candidate["related_applications"] = this._related;
if (this._preferRelated !== undefined)
candidate["prefer_related_applications"] = this._preferRelated;
if (this._shareTarget !== undefined)
candidate["share_target"] = this._shareTarget;
const parsed = WebAppManifestSchema.safeParse(candidate);
if (!parsed.success) {
throw new PwaError(
PWA_CODES.MANIFEST_VALIDATION,
`Web App Manifest failed validation: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
return parsed.data;
}
/** Serialise to an `application/manifest+json` Blob. */
public toBlob(): Blob {
const manifest = this.build();
return new Blob([JSON.stringify(manifest)], {
type: "application/manifest+json",
});
}
}

View File

@@ -0,0 +1 @@
export type { WebAppManifest } from "../schemas/schemas";

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,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPwaObservability } from "./observability";
import type { PwaObservability } from "./observability.types";
describe("createPwaObservability", () => {
it("emits on every documented counter and histogram", {
timeout: 5000,
}, () => {
const obs = createPwaObservability();
const counters = [
obs.counters.swInstalledTotal,
obs.counters.swActivatedTotal,
obs.counters.swUpdatedTotal,
obs.counters.syncEnqueuedTotal,
obs.counters.syncProcessedTotal,
obs.counters.syncFailedTotal,
obs.counters.pushSubscribedTotal,
obs.counters.installPromptTotal,
];
for (const c of counters) {
expect(() => c.add(1, { tenant: "acme" })).not.toThrow();
}
expect(() =>
obs.histograms.syncFlushMs.record(12.3, { kind: "ok" })
).not.toThrow();
expect(() =>
obs.histograms.cacheStrategyMs.record(4.2, {
strategy: "cacheFirst",
})
).not.toThrow();
});
it("passes through a pre-built observability bundle", {
timeout: 5000,
}, () => {
const existing = createPwaObservability();
const passed: PwaObservability = createPwaObservability({
observability: existing,
});
expect(passed).toBe(existing);
});
it("supplies a default meter and tracer when none are provided", {
timeout: 5000,
}, () => {
const obs = createPwaObservability();
expect(obs.meter).toBeDefined();
expect(obs.tracer).toBeDefined();
});
});

View File

@@ -0,0 +1,86 @@
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
import type {
Meter,
PwaObservability,
PwaObservabilityOptions,
Tracer,
} from "./observability.types";
const METER_NAME = "@suaveplan/pwa";
const TRACER_NAME = "@suaveplan/pwa";
/**
* Build a {@link PwaObservability} bundle wired to the supplied (or default
* no-op) meter + tracer. Safe to call from every public entrypoint even when
* no telemetry pipeline is configured.
*/
export function createPwaObservability(
opts?: PwaObservabilityOptions
): PwaObservability {
if (opts?.observability !== undefined) {
return opts.observability;
}
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
swInstalledTotal: createCounter(
meter,
"pwa_sw_installed_total",
"Total service workers that reached the 'installed' state."
),
swActivatedTotal: createCounter(
meter,
"pwa_sw_activated_total",
"Total service workers that reached the 'activated' state."
),
swUpdatedTotal: createCounter(
meter,
"pwa_sw_updated_total",
"Total ServiceWorkerRegistration.update() calls that surfaced a new worker."
),
syncEnqueuedTotal: createCounter(
meter,
"pwa_sync_enqueued_total",
"Total sync-queue entries enqueued."
),
syncProcessedTotal: createCounter(
meter,
"pwa_sync_processed_total",
"Total sync-queue entries successfully processed."
),
syncFailedTotal: createCounter(
meter,
"pwa_sync_failed_total",
"Total sync-queue entries that hit the max-retry cap."
),
pushSubscribedTotal: createCounter(
meter,
"pwa_push_subscribed_total",
"Total push subscriptions successfully created."
),
installPromptTotal: createCounter(
meter,
"pwa_install_prompt_total",
"Total install prompts shown, partitioned by outcome."
),
},
histograms: {
syncFlushMs: createHistogram(
meter,
"pwa_sync_flush_ms",
"Sync-queue flush duration in milliseconds.",
"ms"
),
cacheStrategyMs: createHistogram(
meter,
"pwa_cache_strategy_ms",
"Cache-strategy handler latency in milliseconds.",
"ms"
),
},
};
}

View File

@@ -0,0 +1,39 @@
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
export type { Counter, Histogram, Meter, Tracer };
/** Counters emitted by `@suaveplan/pwa`. */
export interface PwaCounters {
readonly swInstalledTotal: Counter;
readonly swActivatedTotal: Counter;
readonly swUpdatedTotal: Counter;
readonly syncEnqueuedTotal: Counter;
readonly syncProcessedTotal: Counter;
readonly syncFailedTotal: Counter;
readonly pushSubscribedTotal: Counter;
readonly installPromptTotal: Counter;
}
/** Histograms emitted by `@suaveplan/pwa`. */
export interface PwaHistograms {
readonly syncFlushMs: Histogram;
readonly cacheStrategyMs: Histogram;
}
/** Observability bundle used by every public manager. */
export interface PwaObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: PwaCounters;
readonly histograms: PwaHistograms;
}
/** Options consumed by {@link createPwaObservability}. */
export interface PwaObservabilityOptions {
/** Pre-built observability bundle (passes through). */
observability?: PwaObservability;
/** OTel meter to bind counters / histograms to. */
meter?: Meter;
/** OTel tracer for spans. */
tracer?: Tracer;
}

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,258 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PWA_CODES, PwaError } from "../errors/errors";
import type { NotificationStaticLike, PushPermission } from "../types/common";
import { PushNotificationManager } from "./push-notifications";
class FakePushSubscription {
public unsubscribed = false;
public constructor(public readonly endpoint: string) {}
public async unsubscribe(): Promise<boolean> {
this.unsubscribed = true;
return true;
}
}
class FakePushManager {
public lastOptions: PushSubscriptionOptionsInit | null = null;
public currentSub: FakePushSubscription | null = null;
public async subscribe(
options: PushSubscriptionOptionsInit
): Promise<FakePushSubscription> {
this.lastOptions = options;
this.currentSub = new FakePushSubscription("https://push.example/abc");
return this.currentSub;
}
public async getSubscription(): Promise<FakePushSubscription | null> {
return this.currentSub;
}
}
class FakeRegistration {
public pushManager = new FakePushManager();
}
class FakeContainer implements Partial<ServiceWorkerContainer> {
public ready: Promise<ServiceWorkerRegistration>;
public registration = new FakeRegistration();
public constructor() {
this.ready = Promise.resolve(
this.registration as unknown as ServiceWorkerRegistration
);
}
}
function makeNotification(perm: PushPermission): NotificationStaticLike {
return {
permission: perm,
requestPermission: async () => perm,
};
}
describe("PushNotificationManager", () => {
it("reports unsupported when no container is supplied", {
timeout: 5000,
}, async () => {
const push = new PushNotificationManager({
container: undefined,
notification: makeNotification("granted"),
});
expect(push.isSupported).toBe(false);
const result = await push.subscribe();
expect(result.subscribed).toBe(false);
expect(result.error).toBeInstanceOf(PwaError);
expect(result.error?.code).toBe(PWA_CODES.PUSH_UNSUPPORTED);
});
it("reports unsupported when no notification static is available", {
timeout: 5000,
}, async () => {
const original = (globalThis as { Notification?: unknown })
.Notification;
(globalThis as { Notification?: unknown }).Notification = undefined;
try {
const push = new PushNotificationManager({
container:
new FakeContainer() as unknown as ServiceWorkerContainer,
notification: undefined,
});
expect(push.isSupported).toBe(false);
expect(push.getPermission()).toBe("default");
expect(await push.requestPermission()).toBe("default");
expect(await push.unsubscribe()).toBe(false);
expect(await push.getSubscription()).toBeNull();
} finally {
(globalThis as { Notification?: unknown }).Notification = original;
}
});
it("subscribes through the injected PushManager and returns endpoint", {
timeout: 5000,
}, async () => {
const container = new FakeContainer();
const push = new PushNotificationManager({
container: container as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
const validVapid =
"BFa-rtw9CKqL_BdcVqj7m0vYBzcsLkpsZBXmEdYxYsAj42SOPaCQq4qqkKZ9d5R6cy_xpDk0jUVx9b6CgPCu2wo";
const result = await push.subscribe({
applicationServerKey: validVapid,
});
expect(result.subscribed).toBe(true);
expect(result.endpoint).toBe("https://push.example/abc");
const opts = container.registration.pushManager.lastOptions;
expect(opts?.userVisibleOnly).toBe(true);
expect(opts?.applicationServerKey).toBeDefined();
});
it("reports PUSH_PERMISSION_DENIED on denial", {
timeout: 5000,
}, async () => {
const push = new PushNotificationManager({
container: new FakeContainer() as unknown as ServiceWorkerContainer,
notification: makeNotification("denied"),
});
const result = await push.subscribe();
expect(result.subscribed).toBe(false);
expect(result.error).toBeInstanceOf(PwaError);
expect(result.error?.code).toBe(PWA_CODES.PUSH_PERMISSION_DENIED);
});
it("subscribe without VAPID key still sets userVisibleOnly", {
timeout: 5000,
}, async () => {
const container = new FakeContainer();
const push = new PushNotificationManager({
container: container as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
const result = await push.subscribe();
expect(result.subscribed).toBe(true);
const opts = container.registration.pushManager.lastOptions;
expect(opts?.applicationServerKey).toBeUndefined();
expect(opts?.userVisibleOnly).toBe(true);
});
it("unsubscribe returns false when no subscription exists", {
timeout: 5000,
}, async () => {
const push = new PushNotificationManager({
container: new FakeContainer() as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
expect(await push.unsubscribe()).toBe(false);
});
it("unsubscribe returns true when one exists", {
timeout: 5000,
}, async () => {
const container = new FakeContainer();
const push = new PushNotificationManager({
container: container as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
await push.subscribe();
expect(await push.unsubscribe()).toBe(true);
expect(
container.registration.pushManager.currentSub?.unsubscribed
).toBe(true);
});
it("getSubscription returns the current subscription", {
timeout: 5000,
}, async () => {
const container = new FakeContainer();
const push = new PushNotificationManager({
container: container as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
expect(await push.getSubscription()).toBeNull();
await push.subscribe();
expect(await push.getSubscription()).not.toBeNull();
});
it("getPermission and requestPermission delegate to the injected static", {
timeout: 5000,
}, async () => {
const push = new PushNotificationManager({
container: new FakeContainer() as unknown as ServiceWorkerContainer,
notification: makeNotification("default"),
});
expect(push.getPermission()).toBe("default");
expect(await push.requestPermission()).toBe("default");
});
it("derives container + notification from globals when no options are passed", {
timeout: 5000,
}, () => {
const originalNav = (globalThis as { navigator?: Navigator }).navigator;
const originalNotification = (globalThis as { Notification?: unknown })
.Notification;
(globalThis as { navigator?: unknown }).navigator = {
serviceWorker: new FakeContainer(),
};
(globalThis as { Notification?: unknown }).Notification =
makeNotification("granted");
try {
const push = new PushNotificationManager();
expect(push.isSupported).toBe(true);
expect(push.getPermission()).toBe("granted");
} finally {
(globalThis as { navigator?: Navigator }).navigator = originalNav;
if (originalNotification === undefined) {
Reflect.deleteProperty(globalThis as object, "Notification");
} else {
(globalThis as { Notification?: unknown }).Notification =
originalNotification;
}
}
});
it("derives both defaults as undefined when navigator + Notification are absent", {
timeout: 5000,
}, () => {
const originalNav = (globalThis as { navigator?: Navigator }).navigator;
const originalNotification = (globalThis as { Notification?: unknown })
.Notification;
Reflect.deleteProperty(globalThis as object, "navigator");
Reflect.deleteProperty(globalThis as object, "Notification");
try {
const push = new PushNotificationManager();
expect(push.isSupported).toBe(false);
} finally {
(globalThis as { navigator?: Navigator }).navigator = originalNav;
if (originalNotification !== undefined) {
(globalThis as { Notification?: unknown }).Notification =
originalNotification;
}
}
});
it("threads an injected observability bundle through the constructor", {
timeout: 5000,
}, async () => {
const { createPwaObservability } = await import(
"../observability/observability"
);
const observability = createPwaObservability();
const push = new PushNotificationManager({
container: new FakeContainer() as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
observability,
});
const result = await push.subscribe();
expect(result.subscribed).toBe(true);
});
it("honors userVisibleOnly=false", { timeout: 5000 }, async () => {
const container = new FakeContainer();
const push = new PushNotificationManager({
container: container as unknown as ServiceWorkerContainer,
notification: makeNotification("granted"),
});
await push.subscribe({ userVisibleOnly: false });
expect(
container.registration.pushManager.lastOptions?.userVisibleOnly
).toBe(false);
});
});

View File

@@ -0,0 +1,111 @@
import { PWA_CODES, PwaError } from "../errors/errors";
import { createPwaObservability } from "../observability/observability";
import type { PwaObservability } from "../observability/observability.types";
import type {
NotificationStaticLike,
PushNotificationOptions,
PushPermission,
PushSubscriptionResult,
} from "../types/common";
import type {
IPushNotificationManager,
PushNotificationManagerOptions,
} from "./push-notifications.types";
import { urlBase64ToUint8Array } from "./vapid";
/**
* Manages push notification subscriptions and permissions. Wraps the
* Push API behind injectable seams so tests drive permission, subscription
* lifecycle, and the underlying `PushManager` without global stubbing.
*/
export class PushNotificationManager implements IPushNotificationManager {
private readonly _container: ServiceWorkerContainer | undefined;
private readonly _notification: NotificationStaticLike | undefined;
private readonly _observability: PwaObservability;
public constructor(options: PushNotificationManagerOptions = {}) {
this._container =
options.container ??
(typeof navigator !== "undefined" && "serviceWorker" in navigator
? navigator.serviceWorker
: undefined);
this._notification =
options.notification ??
(typeof globalThis !== "undefined" && "Notification" in globalThis
? (globalThis as { Notification: NotificationStaticLike })
.Notification
: undefined);
this._observability = createPwaObservability(
options.observability !== undefined
? { observability: options.observability }
: undefined
);
}
public get isSupported(): boolean {
return (
this._container !== undefined && this._notification !== undefined
);
}
public getPermission(): PushPermission {
if (this._notification === undefined) return "default";
return this._notification.permission;
}
public async requestPermission(): Promise<PushPermission> {
if (this._notification === undefined) return "default";
return await this._notification.requestPermission();
}
public async subscribe(
options: PushNotificationOptions = {}
): Promise<PushSubscriptionResult> {
if (!this.isSupported) {
return {
subscribed: false,
error: new PwaError(
PWA_CODES.PUSH_UNSUPPORTED,
"Push notifications are not supported in this runtime"
),
};
}
const permission = await this.requestPermission();
if (permission !== "granted") {
return {
subscribed: false,
error: new PwaError(
PWA_CODES.PUSH_PERMISSION_DENIED,
`Notification permission is "${permission}"`
),
};
}
const registration = await this._container!.ready;
const subscribeOptions: PushSubscriptionOptionsInit = {
userVisibleOnly: options.userVisibleOnly ?? true,
};
if (options.applicationServerKey !== undefined) {
subscribeOptions.applicationServerKey = urlBase64ToUint8Array(
options.applicationServerKey
) as BufferSource;
}
const subscription =
await registration.pushManager.subscribe(subscribeOptions);
this._observability.counters.pushSubscribedTotal.add(1);
return { subscribed: true, endpoint: subscription.endpoint };
}
public async unsubscribe(): Promise<boolean> {
if (!this.isSupported) return false;
const registration = await this._container!.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription === null) return false;
return await subscription.unsubscribe();
}
public async getSubscription(): Promise<PushSubscription | null> {
if (!this.isSupported) return null;
const registration = await this._container!.ready;
return await registration.pushManager.getSubscription();
}
}

View File

@@ -0,0 +1,29 @@
import type { PwaObservability } from "../observability/observability.types";
import type {
NotificationStaticLike,
PushNotificationOptions,
PushPermission,
PushSubscriptionResult,
} from "../types/common";
/** Options consumed by `PushNotificationManager` constructor. */
export interface PushNotificationManagerOptions {
/** ServiceWorkerContainer seam. Defaults to `navigator.serviceWorker`. */
container?: ServiceWorkerContainer;
/** Notification static seam. Defaults to `globalThis.Notification`. */
notification?: NotificationStaticLike;
/** Optional observability bundle. */
observability?: PwaObservability;
}
/** Public surface of `PushNotificationManager`. */
export interface IPushNotificationManager {
readonly isSupported: boolean;
getPermission(): PushPermission;
requestPermission(): Promise<PushPermission>;
subscribe(
options?: PushNotificationOptions
): Promise<PushSubscriptionResult>;
unsubscribe(): Promise<boolean>;
getSubscription(): Promise<PushSubscription | null>;
}

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,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { urlBase64ToUint8Array } from "./vapid";
describe("urlBase64ToUint8Array", () => {
it("decodes padded and unpadded VAPID keys identically", {
timeout: 5000,
}, () => {
const unpadded =
"BFa-rtw9CKqL_BdcVqj7m0vYBzcsLkpsZBXmEdYxYsAj42SOPaCQq4qqkKZ9d5R6cy_xpDk0jUVx9b6CgPCu2wo";
const padded = `${unpadded}=`;
const a = urlBase64ToUint8Array(unpadded);
const b = urlBase64ToUint8Array(padded);
expect(a.length).toBe(65);
expect(b.length).toBe(65);
expect(Array.from(a)).toEqual(Array.from(b));
});
it("round-trips simple known bytes", { timeout: 5000 }, () => {
// "Aa" in base64 (URL-safe) is "QWE="
const out = urlBase64ToUint8Array("QWE");
expect(out.length).toBe(2);
expect(out[0]).toBe(65);
expect(out[1]).toBe(97);
});
it("handles URL-safe replacements", { timeout: 5000 }, () => {
// bytes [251, 255, 191] → standard base64 +/+/ = "+/+/" → URL-safe "-_-_"
const standard = "-_-_";
const out = urlBase64ToUint8Array(standard);
expect(out.length).toBeGreaterThan(0);
});
it("throws on invalid base64", { timeout: 5000 }, () => {
expect(() => urlBase64ToUint8Array("not%base64!")).toThrow();
});
});

View File

@@ -0,0 +1,15 @@
/**
* Decode a URL-safe base64 VAPID public key into a `Uint8Array`. Accepts both
* padded and unpadded input. Invalid base64 raises a `DOMException` /
* `Error` depending on the underlying `atob` implementation.
*/
export function urlBase64ToUint8Array(key: string): Uint8Array {
const padding = "=".repeat((4 - (key.length % 4)) % 4);
const base64 = (key + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) {
out[i] = raw.charCodeAt(i);
}
return out;
}

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,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import {
JsonValueSchema,
SyncQueueEntrySchema,
WebAppManifestSchema,
} from "./schemas";
describe("SyncQueueEntrySchema", () => {
const valid = {
id: "sq_1",
url: "/api/v1/orders",
method: "POST",
createdAt: 1_700_000_000_000,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
it("accepts a fully-populated entry", { timeout: 5000 }, () => {
const parsed = SyncQueueEntrySchema.parse({
...valid,
headers: { "x-trace": "abc" },
body: '{"a":1}',
metadata: { tenant: "acme", attempts: 2, flags: [true, "x"] },
});
expect(parsed.id).toBe("sq_1");
expect(parsed.metadata?.tenant).toBe("acme");
});
it("rejects entries with the wrong schemaVersion", {
timeout: 5000,
}, () => {
expect(() =>
SyncQueueEntrySchema.parse({ ...valid, schemaVersion: 1 })
).toThrow();
});
it("rejects negative retryCount", { timeout: 5000 }, () => {
expect(() =>
SyncQueueEntrySchema.parse({ ...valid, retryCount: -1 })
).toThrow();
});
it("rejects metadata containing non-JSON values", { timeout: 5000 }, () => {
expect(() =>
SyncQueueEntrySchema.parse({
...valid,
metadata: { fn: () => 1 },
})
).toThrow();
});
});
describe("JsonValueSchema", () => {
it("accepts nested JSON values", { timeout: 5000 }, () => {
expect(() =>
JsonValueSchema.parse({
a: [1, "x", true, null, { b: [false, "y"] }],
})
).not.toThrow();
});
it("rejects functions", { timeout: 5000 }, () => {
expect(() => JsonValueSchema.parse(() => 1)).toThrow();
});
});
describe("WebAppManifestSchema", () => {
it("accepts a minimal valid manifest", { timeout: 5000 }, () => {
const parsed = WebAppManifestSchema.parse({
name: "Suaveplan",
icons: [{ src: "/icon.png", sizes: "512x512" }],
});
expect(parsed.name).toBe("Suaveplan");
});
it("rejects manifests without icons", { timeout: 5000 }, () => {
expect(() =>
WebAppManifestSchema.parse({ name: "X", icons: [] })
).toThrow();
});
it("rejects manifests with invalid display", { timeout: 5000 }, () => {
expect(() =>
WebAppManifestSchema.parse({
name: "X",
icons: [{ src: "/i.png", sizes: "1x1" }],
display: "bogus",
})
).toThrow();
});
});

View File

@@ -0,0 +1,109 @@
import z from "zod";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
const JSON_VALUE_LITERAL = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
/** Recursive JSON-safe value. Rejects functions, symbols, undefined. */
export const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
JSON_VALUE_LITERAL,
z.array(JsonValueSchema),
z.record(z.string(), JsonValueSchema),
])
);
/** Schema for a single sync-queue entry at the current schema version. */
export const SyncQueueEntrySchema = z.object({
id: z.string().min(1),
url: z.string().min(1),
method: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
createdAt: z.number().int().nonnegative(),
retryCount: z.number().int().nonnegative(),
maxRetries: z.number().int().nonnegative(),
metadata: z.record(z.string(), JsonValueSchema).optional(),
schemaVersion: z.literal(SYNC_QUEUE_SCHEMA_VERSION),
});
/** Schema for a manifest icon. */
export const ManifestIconSchema = z.object({
src: z.string().min(1),
sizes: z.string().min(1),
type: z.string().min(1).optional(),
purpose: z.string().min(1).optional(),
});
/** Schema for a manifest shortcut. */
export const ManifestShortcutSchema = z.object({
name: z.string().min(1),
shortName: z.string().min(1).optional(),
description: z.string().min(1).optional(),
url: z.string().min(1),
icons: z.array(ManifestIconSchema).optional(),
});
/** Schema for a related application entry. */
export const RelatedApplicationSchema = z.object({
platform: z.string().min(1),
url: z.string().min(1).optional(),
id: z.string().min(1).optional(),
});
/** Schema for a share-target descriptor. */
export const ShareTargetSchema = z.object({
action: z.string().min(1),
method: z.enum(["GET", "POST"]).optional(),
enctype: z.string().min(1).optional(),
params: z
.object({
title: z.string().optional(),
text: z.string().optional(),
url: z.string().optional(),
})
.optional(),
});
/** Schema for a Web App Manifest. */
export const WebAppManifestSchema = z.object({
name: z.string().min(1),
short_name: z.string().min(1).optional(),
description: z.string().min(1).optional(),
start_url: z.string().min(1).optional(),
scope: z.string().min(1).optional(),
display: z
.enum(["fullscreen", "standalone", "minimal-ui", "browser"])
.optional(),
orientation: z
.enum([
"any",
"natural",
"landscape",
"portrait",
"portrait-primary",
"portrait-secondary",
"landscape-primary",
"landscape-secondary",
])
.optional(),
background_color: z.string().min(1).optional(),
theme_color: z.string().min(1).optional(),
lang: z.string().min(1).optional(),
dir: z.enum(["ltr", "rtl", "auto"]).optional(),
icons: z.array(ManifestIconSchema).min(1),
shortcuts: z.array(ManifestShortcutSchema).optional(),
related_applications: z.array(RelatedApplicationSchema).optional(),
prefer_related_applications: z.boolean().optional(),
share_target: ShareTargetSchema.optional(),
});
export type ManifestIcon = z.infer<typeof ManifestIconSchema>;
export type ManifestShortcut = z.infer<typeof ManifestShortcutSchema>;
export type RelatedApplication = z.infer<typeof RelatedApplicationSchema>;
export type ShareTarget = z.infer<typeof ShareTargetSchema>;
export type WebAppManifest = z.infer<typeof WebAppManifestSchema>;

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,557 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { PWA_CODES, PwaError } from "../errors/errors";
import { ServiceWorkerManager } from "./service-worker";
import type { IntervalScheduler } from "./service-worker.types";
class FakeServiceWorker extends EventTarget implements Partial<ServiceWorker> {
public state: ServiceWorkerState = "installing";
public postedMessages: unknown[] = [];
public postMessage(message: unknown): void {
this.postedMessages.push(message);
}
public transitionTo(state: ServiceWorkerState): void {
this.state = state;
this.dispatchEvent(new Event("statechange"));
}
}
class FakeRegistration extends EventTarget {
public installing: FakeServiceWorker | null = null;
public waiting: FakeServiceWorker | null = null;
public active: FakeServiceWorker | null = null;
public updateCount = 0;
public updateThrow: Error | null = null;
public unregisterResult = true;
public async update(): Promise<void> {
this.updateCount += 1;
if (this.updateThrow !== null) throw this.updateThrow;
}
public async unregister(): Promise<boolean> {
return this.unregisterResult;
}
public triggerUpdateFound(worker: FakeServiceWorker): void {
this.installing = worker;
this.dispatchEvent(new Event("updatefound"));
}
}
class FakeContainer implements Partial<ServiceWorkerContainer> {
public lastRegistration: FakeRegistration | null = null;
public registerThrow: Error | null = null;
public controller: ServiceWorker | null = null;
public lastScriptUrl: string | null = null;
public lastScope: string | null = null;
/** Optional pre-seeded registration to hand out on the next `register()` call. */
public seedRegistration: FakeRegistration | null = null;
public async register(
scriptUrl: string,
options?: { scope?: string }
): Promise<FakeRegistration> {
this.lastScriptUrl = scriptUrl;
this.lastScope = options?.scope ?? null;
if (this.registerThrow !== null) throw this.registerThrow;
const reg = this.seedRegistration ?? new FakeRegistration();
this.seedRegistration = null;
this.lastRegistration = reg;
return reg;
}
}
class FakeIntervals implements IntervalScheduler {
public schedules: { handle: number; cb: () => void; delay: number }[] = [];
private _nextHandle = 1;
public setInterval(callback: () => void, delayMs: number): number {
const handle = this._nextHandle++;
this.schedules.push({ handle, cb: callback, delay: delayMs });
return handle;
}
public clearInterval(handle: number): void {
this.schedules = this.schedules.filter((s) => s.handle !== handle);
}
public tick(handle: number): void {
const s = this.schedules.find((x) => x.handle === handle);
s?.cb();
}
}
describe("ServiceWorkerManager", () => {
let container: FakeContainer;
let intervals: FakeIntervals;
beforeEach(() => {
container = new FakeContainer();
intervals = new FakeIntervals();
});
it("is unsupported when no container is supplied and navigator lacks one", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: undefined,
intervals,
});
const result = await swm.register({ scriptUrl: "/sw.js" });
expect(result.success).toBe(false);
expect(result.state).toBe("unsupported");
expect(result.error).toBeInstanceOf(PwaError);
expect(result.error?.code).toBe(PWA_CODES.SW_UNSUPPORTED);
});
it("transitions through every lifecycle state via the injected container", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const states: string[] = [];
swm.onStateChange((s) => states.push(s));
const installing = new FakeServiceWorker();
const seed = new FakeRegistration();
seed.installing = installing;
container.seedRegistration = seed;
const result = await swm.register({
scriptUrl: "/sw.js",
onSuccess: () => states.push("onSuccess"),
onUpdate: () => states.push("onUpdate"),
});
expect(result.success).toBe(true);
const lastReg = container.lastRegistration;
expect(lastReg).not.toBeNull();
if (lastReg !== null && lastReg.installing !== null) {
const w = lastReg.installing;
w.transitionTo("installed");
w.transitionTo("activated");
}
expect(states).toContain("installing");
expect(states).toContain("installed");
expect(states).toContain("activated");
expect(states).toContain("onSuccess");
});
it("handles waiting-on-register path", { timeout: 5000 }, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
seed.waiting = new FakeServiceWorker();
container.seedRegistration = seed;
const updates: ServiceWorkerRegistration[] = [];
await swm.register({
scriptUrl: "/sw.js",
onUpdate: (r) => updates.push(r),
});
expect(updates.length).toBe(1);
expect(swm.state).toBe("installed");
});
it("handles active-on-register path", { timeout: 5000 }, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
seed.active = new FakeServiceWorker();
container.seedRegistration = seed;
const successes: ServiceWorkerRegistration[] = [];
await swm.register({
scriptUrl: "/sw.js",
onSuccess: (r) => successes.push(r),
});
expect(successes.length).toBe(1);
expect(swm.state).toBe("activated");
});
it("schedules update polling and stops on unregister", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 100 });
expect(intervals.schedules.length).toBe(1);
intervals.tick(intervals.schedules[0]!.handle);
await new Promise((r) => setTimeout(r, 1));
expect(container.lastRegistration?.updateCount).toBe(1);
const unregistered = await swm.unregister();
expect(unregistered).toBe(true);
expect(intervals.schedules.length).toBe(0);
});
it("responds to updatefound by entering installing state", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
const reg = container.lastRegistration;
expect(reg).not.toBeNull();
if (reg !== null) {
const newWorker = new FakeServiceWorker();
reg.triggerUpdateFound(newWorker);
expect(swm.state).toBe("installing");
newWorker.transitionTo("installed");
expect(swm.state).toBe("installed");
newWorker.transitionTo("redundant");
expect(swm.state).toBe("redundant");
}
});
it("treats controller-present installed as an update", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
container.controller =
new FakeServiceWorker() as unknown as ServiceWorker;
const updates: ServiceWorkerRegistration[] = [];
await swm.register({
scriptUrl: "/sw.js",
onUpdate: (r) => updates.push(r),
updateInterval: 0,
});
const reg = container.lastRegistration;
expect(reg).not.toBeNull();
if (reg !== null) {
const newWorker = new FakeServiceWorker();
reg.triggerUpdateFound(newWorker);
newWorker.transitionTo("installed");
expect(updates.length).toBe(1);
}
});
it("surfaces registration rejection as PwaError", {
timeout: 5000,
}, async () => {
container.registerThrow = new TypeError("script not reachable");
const errors: Error[] = [];
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const result = await swm.register({
scriptUrl: "/sw.js",
onError: (e) => errors.push(e),
});
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(PwaError);
expect(result.error?.code).toBe(PWA_CODES.SW_REGISTRATION_FAILED);
expect(result.error?.cause).toBeInstanceOf(TypeError);
expect(errors.length).toBe(1);
});
it("wraps non-Error throws into PwaError with Error cause", {
timeout: 5000,
}, async () => {
container.registerThrow = "string-thrown" as unknown as Error;
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const result = await swm.register({ scriptUrl: "/sw.js" });
expect(result.error).toBeInstanceOf(PwaError);
expect(result.error?.cause).toBeInstanceOf(Error);
});
it("checkForUpdate returns true on success and false on rejection", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
expect(await swm.checkForUpdate()).toBe(true);
if (container.lastRegistration !== null) {
container.lastRegistration.updateThrow = new Error("nope");
}
expect(await swm.checkForUpdate()).toBe(false);
});
it("checkForUpdate returns false when no registration exists", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
expect(await swm.checkForUpdate()).toBe(false);
});
it("skipWaiting messages the waiting worker", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
const waiting = new FakeServiceWorker();
seed.waiting = waiting;
container.seedRegistration = seed;
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
swm.skipWaiting();
expect(waiting.postedMessages).toEqual([{ type: "SKIP_WAITING" }]);
});
it("skipWaiting is a no-op when no registration or waiting worker exists", {
timeout: 5000,
}, () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
expect(() => swm.skipWaiting()).not.toThrow();
});
it("onStateChange unsubscribe stops further notifications", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const states: string[] = [];
const off = swm.onStateChange((s) => states.push(s));
const seed = new FakeRegistration();
const installing = new FakeServiceWorker();
seed.installing = installing;
container.seedRegistration = seed;
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
off();
// Trigger another state transition
installing.transitionTo("installed");
expect(states.includes("installed")).toBe(false);
});
it("unregister returns false when no registration exists", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
expect(await swm.unregister()).toBe(false);
});
it("passes scope through to the container", { timeout: 5000 }, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({
scriptUrl: "/sw.js",
scope: "/app/",
updateInterval: 0,
});
expect(container.lastScope).toBe("/app/");
});
it("exposes the underlying registration via the getter", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
expect(swm.registration).toBeNull();
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
expect(swm.registration).not.toBeNull();
});
it("exposes the injected container as supported", { timeout: 5000 }, () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
expect(swm.isSupported).toBe(true);
});
it("falls back to globalThis.setInterval/clearInterval when no scheduler is injected", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
});
// updateInterval=1 wires the real timer via DEFAULT_INTERVALS — then
// unregister tears it down to ensure both setInterval + clearInterval
// paths execute against the platform globals.
const result = await swm.register({
scriptUrl: "/sw.js",
updateInterval: 1,
});
expect(result.success).toBe(true);
const unregistered = await swm.unregister();
expect(unregistered).toBe(true);
});
it("derives the container from navigator.serviceWorker when none is injected", {
timeout: 5000,
}, () => {
const originalNav = (globalThis as { navigator?: Navigator }).navigator;
(globalThis as { navigator?: unknown }).navigator = {
serviceWorker: container,
};
try {
const swm = new ServiceWorkerManager();
expect(swm.isSupported).toBe(true);
} finally {
(globalThis as { navigator?: unknown }).navigator = originalNav;
}
});
it("is unsupported when navigator is undefined entirely", {
timeout: 5000,
}, () => {
const originalNav = (globalThis as { navigator?: Navigator }).navigator;
// biome cannot statically reject delete on a typed cast; we do this
// to make `typeof navigator === "undefined"` true at construction.
Reflect.deleteProperty(globalThis as object, "navigator");
try {
const swm = new ServiceWorkerManager();
expect(swm.isSupported).toBe(false);
} finally {
(globalThis as { navigator?: Navigator }).navigator = originalNav;
}
});
it("swallows update() rejections from the interval tick", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 100 });
if (container.lastRegistration !== null) {
container.lastRegistration.updateThrow = new Error("rejected");
}
// Tick fires registration.update().catch(() => {}) — must not throw.
intervals.tick(intervals.schedules[0]!.handle);
// Give the rejected promise a tick to resolve through .catch.
await new Promise((r) => setTimeout(r, 5));
expect(container.lastRegistration?.updateCount).toBe(1);
});
it("ignores updatefound events when registration.installing is null", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
const reg = container.lastRegistration;
expect(reg).not.toBeNull();
if (reg !== null) {
// Fire updatefound without setting installing → falsy `newWorker !== null` arm.
reg.installing = null;
reg.dispatchEvent(new Event("updatefound"));
// State remains in its post-register form (no transition fired).
expect(swm.state).toBeDefined();
}
});
it("returns false when the registration's unregister() rejects the removal", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
seed.unregisterResult = false;
container.seedRegistration = seed;
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
// result === false → registration stays attached and state does not flip.
expect(await swm.unregister()).toBe(false);
expect(swm.registration).not.toBeNull();
});
it("onStateChange unsubscribe is idempotent", { timeout: 5000 }, () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const off = swm.onStateChange(() => {});
off();
// Second call: indexOf returns -1, splice skipped.
expect(() => off()).not.toThrow();
});
it("ignores worker statechange events for unrecognised states", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
const installing = new FakeServiceWorker();
seed.installing = installing;
container.seedRegistration = seed;
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
const before = swm.state;
// "parsed" is a real ServiceWorker state outside the installed /
// activated / redundant branches → falls through every else-if arm.
installing.transitionTo("parsed");
expect(swm.state).toBe(before);
});
it("transitions to redundant when the worker is marked redundant", {
timeout: 5000,
}, async () => {
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
});
const seed = new FakeRegistration();
const installing = new FakeServiceWorker();
seed.installing = installing;
container.seedRegistration = seed;
await swm.register({ scriptUrl: "/sw.js", updateInterval: 0 });
installing.transitionTo("redundant");
expect(swm.state).toBe("redundant");
});
it("threads an injected observability bundle through the constructor", {
timeout: 5000,
}, async () => {
const { createPwaObservability } = await import(
"../observability/observability"
);
const observability = createPwaObservability();
const swm = new ServiceWorkerManager({
container: container as unknown as ServiceWorkerContainer,
intervals,
observability,
});
expect(swm.isSupported).toBe(true);
});
it("is unsupported when navigator lacks the serviceWorker key", {
timeout: 5000,
}, () => {
const originalNav = (globalThis as { navigator?: Navigator }).navigator;
(globalThis as { navigator?: unknown }).navigator = {};
try {
const swm = new ServiceWorkerManager();
expect(swm.isSupported).toBe(false);
} finally {
(globalThis as { navigator?: Navigator }).navigator = originalNav;
}
});
});

View File

@@ -0,0 +1,195 @@
import { DEFAULT_UPDATE_INTERVAL } from "../constants/index";
import { PWA_CODES, PwaError } from "../errors/errors";
import { createPwaObservability } from "../observability/observability";
import type { PwaObservability } from "../observability/observability.types";
import type {
SwRegistrationOptions,
SwRegistrationResult,
SwState,
} from "../types/common";
import type {
IntervalScheduler,
IServiceWorkerManager,
ServiceWorkerManagerOptions,
} from "./service-worker.types";
const DEFAULT_INTERVALS: IntervalScheduler = {
setInterval: (cb, ms) =>
globalThis.setInterval(cb, ms) as unknown as number,
clearInterval: (handle) =>
globalThis.clearInterval(
handle as unknown as ReturnType<typeof setInterval>
),
};
/**
* Manages service worker registration, updates, and lifecycle. Wraps the
* `ServiceWorkerContainer` API behind an injectable seam so tests can drive
* every transition without touching `navigator`.
*/
export class ServiceWorkerManager implements IServiceWorkerManager {
private _registration: ServiceWorkerRegistration | null = null;
private _state: SwState = "unsupported";
private _updateHandle: number | null = null;
private readonly _listeners: ((state: SwState) => void)[] = [];
private readonly _container: ServiceWorkerContainer | undefined;
private readonly _intervals: IntervalScheduler;
private readonly _observability: PwaObservability;
public constructor(options: ServiceWorkerManagerOptions = {}) {
this._container =
options.container ??
(typeof navigator !== "undefined" && "serviceWorker" in navigator
? navigator.serviceWorker
: undefined);
this._intervals = options.intervals ?? DEFAULT_INTERVALS;
this._observability = createPwaObservability(
options.observability !== undefined
? { observability: options.observability }
: undefined
);
}
public get state(): SwState {
return this._state;
}
public get registration(): ServiceWorkerRegistration | null {
return this._registration;
}
public get isSupported(): boolean {
return this._container !== undefined;
}
public async register(
options: SwRegistrationOptions
): Promise<SwRegistrationResult> {
if (this._container === undefined) {
const error = new PwaError(
PWA_CODES.SW_UNSUPPORTED,
"Service workers are not supported in this runtime"
);
options.onError?.(error);
return { success: false, state: "unsupported", error };
}
try {
const registration = await this._container.register(
options.scriptUrl,
options.scope !== undefined ? { scope: options.scope } : {}
);
this._registration = registration;
if (registration.installing !== null) {
this._setState("installing");
this._trackInstalling(registration.installing, options);
} else if (registration.waiting !== null) {
this._setState("installed");
this._observability.counters.swInstalledTotal.add(1);
options.onUpdate?.(registration);
} else if (registration.active !== null) {
this._setState("activated");
this._observability.counters.swActivatedTotal.add(1);
options.onSuccess?.(registration);
}
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (newWorker !== null) {
this._setState("installing");
this._trackInstalling(newWorker, options);
}
});
const interval = options.updateInterval ?? DEFAULT_UPDATE_INTERVAL;
if (interval > 0) {
this._updateHandle = this._intervals.setInterval(() => {
void registration.update().catch(() => {});
}, interval);
}
return { success: true, state: this._state };
} catch (cause) {
const message =
cause instanceof Error ? cause.message : String(cause);
const error = new PwaError(
PWA_CODES.SW_REGISTRATION_FAILED,
`Service worker registration failed: ${message}`,
cause instanceof Error
? { cause }
: { cause: new Error(message) }
);
options.onError?.(error);
return { success: false, state: "unsupported", error };
}
}
public async unregister(): Promise<boolean> {
if (this._updateHandle !== null) {
this._intervals.clearInterval(this._updateHandle);
this._updateHandle = null;
}
if (this._registration === null) return false;
const result = await this._registration.unregister();
if (result) {
this._registration = null;
this._setState("redundant");
}
return result;
}
public async checkForUpdate(): Promise<boolean> {
if (this._registration === null) return false;
try {
await this._registration.update();
this._observability.counters.swUpdatedTotal.add(1);
return true;
} catch {
return false;
}
}
public skipWaiting(): void {
const waiting = this._registration?.waiting;
if (waiting !== null && waiting !== undefined) {
waiting.postMessage({ type: "SKIP_WAITING" });
}
}
public onStateChange(listener: (state: SwState) => void): () => void {
this._listeners.push(listener);
return () => {
const idx = this._listeners.indexOf(listener);
if (idx >= 0) this._listeners.splice(idx, 1);
};
}
private _setState(state: SwState): void {
this._state = state;
for (const listener of this._listeners) {
listener(state);
}
}
private _trackInstalling(
worker: ServiceWorker,
options: SwRegistrationOptions
): void {
// `_trackInstalling` is only called immediately after `_registration`
// is assigned, so the captured snapshot is always non-null here.
const registration = this._registration as ServiceWorkerRegistration;
worker.addEventListener("statechange", () => {
if (worker.state === "installed") {
this._setState("installed");
this._observability.counters.swInstalledTotal.add(1);
const controller = this._container?.controller;
if (controller !== null && controller !== undefined) {
options.onUpdate?.(registration);
} else {
options.onSuccess?.(registration);
}
} else if (worker.state === "activated") {
this._setState("activated");
this._observability.counters.swActivatedTotal.add(1);
} else if (worker.state === "redundant") {
this._setState("redundant");
}
});
}
}

View File

@@ -0,0 +1,36 @@
import type { PwaObservability } from "../observability/observability.types";
import type {
SwRegistrationOptions,
SwRegistrationResult,
SwState,
} from "../types/common";
/** Injectable interval scheduler. */
export interface IntervalScheduler {
/** Schedule a recurring callback every `delayMs`. Returns a handle. */
setInterval(callback: () => void, delayMs: number): number;
/** Cancel a recurring callback by handle. Idempotent. */
clearInterval(handle: number): void;
}
/** Options consumed by {@link IServiceWorkerManager}. */
export interface ServiceWorkerManagerOptions {
/** ServiceWorkerContainer seam. Defaults to `navigator.serviceWorker`. */
container?: ServiceWorkerContainer;
/** Recurring scheduler seam. Defaults to global `setInterval`. */
intervals?: IntervalScheduler;
/** Optional observability bundle. */
observability?: PwaObservability;
}
/** Interface for the ServiceWorkerManager class. */
export interface IServiceWorkerManager {
readonly state: SwState;
readonly registration: ServiceWorkerRegistration | null;
readonly isSupported: boolean;
register(options: SwRegistrationOptions): Promise<SwRegistrationResult>;
unregister(): Promise<boolean>;
checkForUpdate(): Promise<boolean>;
skipWaiting(): void;
onStateChange(listener: (state: SwState) => void): () => void;
}

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,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BackgroundSyncBridge,
type VisibilityDocumentLike,
} from "./background-sync";
class FakeDocument implements VisibilityDocumentLike {
public visibilityState: "visible" | "hidden" | "prerender" = "hidden";
private listeners: (() => void)[] = [];
public addEventListener(_t: "visibilitychange", l: () => void): void {
this.listeners.push(l);
}
public removeEventListener(_t: "visibilitychange", l: () => void): void {
this.listeners = this.listeners.filter((x) => x !== l);
}
public setVisible(): void {
this.visibilityState = "visible";
for (const l of [...this.listeners]) l();
}
public setHidden(): void {
this.visibilityState = "hidden";
for (const l of [...this.listeners]) l();
}
public listenerCount(): number {
return this.listeners.length;
}
}
describe("BackgroundSyncBridge", () => {
it("uses the native SyncManager when available", {
timeout: 5000,
}, async () => {
const tags: string[] = [];
const bridge = new BackgroundSyncBridge({
registration: {
sync: { register: async (t) => void tags.push(t) },
},
});
const mode = await bridge.register("retry", async () => {});
expect(mode).toBe("native");
expect(tags).toEqual(["retry"]);
expect(bridge.tag).toBe("retry");
});
it("falls back to visibilitychange when SyncManager is absent", {
timeout: 5000,
}, async () => {
const doc = new FakeDocument();
let flushes = 0;
const bridge = new BackgroundSyncBridge({
registration: {},
document: doc,
});
const mode = await bridge.register("retry", () => {
flushes += 1;
});
expect(mode).toBe("visibility");
doc.setVisible();
doc.setHidden();
doc.setVisible();
await new Promise((r) => setTimeout(r, 5));
expect(flushes).toBe(2);
});
it("unregister() removes the visibility listener", {
timeout: 5000,
}, async () => {
const doc = new FakeDocument();
const bridge = new BackgroundSyncBridge({
registration: {},
document: doc,
});
await bridge.register("t", () => {});
expect(doc.listenerCount()).toBe(1);
bridge.unregister();
expect(doc.listenerCount()).toBe(0);
expect(bridge.tag).toBeNull();
});
it("throws when neither sync nor document is supplied", {
timeout: 5000,
}, async () => {
const bridge = new BackgroundSyncBridge({ registration: {} });
await expect(bridge.register("t", () => {})).rejects.toThrow();
});
it("unregister() is a no-op on the native path", {
timeout: 5000,
}, async () => {
const bridge = new BackgroundSyncBridge({
registration: {
sync: { register: async () => {} },
},
});
await bridge.register("t", () => {});
// Native path did not install a visibility binding → unregister is a
// pure cleanup of tag + flush only.
expect(() => bridge.unregister()).not.toThrow();
expect(bridge.tag).toBeNull();
});
it("tolerates flush errors in the visibility path", {
timeout: 5000,
}, async () => {
const doc = new FakeDocument();
const bridge = new BackgroundSyncBridge({
registration: {},
document: doc,
});
await bridge.register("t", async () => {
throw new Error("boom");
});
doc.setVisible();
await new Promise((r) => setTimeout(r, 5));
// didn't crash
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
/** Minimal `SyncManager`-shaped seam (it's experimental in lib.dom.d.ts). */
export interface SyncManagerLike {
register(tag: string): Promise<void>;
}
/** Registration shape consumed by {@link BackgroundSyncBridge}. */
export interface SyncCapableRegistration {
readonly sync?: SyncManagerLike;
}
/** Minimal document-like seam exposing `visibilityState` + add/removeEventListener. */
export interface VisibilityDocumentLike {
readonly visibilityState: "visible" | "hidden" | "prerender";
addEventListener(type: "visibilitychange", listener: () => void): void;
removeEventListener(type: "visibilitychange", listener: () => void): void;
}
/** Options consumed by {@link BackgroundSyncBridge}. */
export interface BackgroundSyncBridgeOptions {
/** Registration that may expose a `SyncManager`. */
registration: SyncCapableRegistration;
/** Document seam. Required when falling back to visibility-change. */
document?: VisibilityDocumentLike;
}
/**
* Bridges the standard `SyncManager.register(tag)` API for native background
* sync. When the runtime does not expose `registration.sync`, the bridge
* falls back to a `visibilitychange` listener that runs the supplied
* `flush()` whenever the tab becomes visible.
*/
export class BackgroundSyncBridge {
private readonly _registration: SyncCapableRegistration;
private readonly _document: VisibilityDocumentLike | undefined;
private _tag: string | null = null;
private _flush: (() => void | Promise<void>) | null = null;
private _binding: {
listener: () => void;
document: VisibilityDocumentLike;
} | null = null;
private _lastState: string | null = null;
public constructor(options: BackgroundSyncBridgeOptions) {
this._registration = options.registration;
this._document = options.document;
}
/**
* Register a background-sync tag and a fallback flush function. The flush
* is invoked only by the fallback path; in native mode the user's SW is
* responsible for handling the corresponding `sync` event.
*/
public async register(
tag: string,
flush: () => void | Promise<void>
): Promise<"native" | "visibility"> {
this._tag = tag;
this._flush = flush;
const sync = this._registration.sync;
if (sync !== undefined) {
await sync.register(tag);
return "native";
}
if (this._document !== undefined) {
const doc = this._document;
const listener = (): void => {
const next = doc.visibilityState;
if (next === "visible" && this._lastState !== "visible") {
this._lastState = "visible";
void Promise.resolve(this._flush?.()).catch(() => {});
} else {
this._lastState = next;
}
};
this._binding = { listener, document: doc };
doc.addEventListener("visibilitychange", listener);
return "visibility";
}
throw new Error(
"BackgroundSyncBridge requires either a SyncManager-capable registration or a document seam"
);
}
/** Unregister the visibility fallback (no-op for the native path). */
public unregister(): void {
if (this._binding !== null) {
this._binding.document.removeEventListener(
"visibilitychange",
this._binding.listener
);
this._binding = null;
}
this._tag = null;
this._flush = null;
}
/** Currently-registered tag (or `null`). */
public get tag(): string | null {
return this._tag;
}
}

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,437 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import type { SyncQueueEntry } from "../types/common";
import { IndexedDbStore } from "./indexeddb-store";
const entry: SyncQueueEntry = {
id: "sq_1",
url: "/api/x",
method: "POST",
createdAt: 1,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
function newFactory(): IDBFactory {
return new (FDBFactory as unknown as new () => IDBFactory)();
}
describe("IndexedDbStore", () => {
let factory: IDBFactory;
beforeEach(() => {
factory = newFactory();
});
it("passes conformance harness", { timeout: 5000 }, async () => {
const store = new IndexedDbStore({
factory,
databaseName: "test_a",
});
expect(await store.load()).toEqual([]);
await store.save([entry]);
const loaded = await store.load();
expect(loaded.length).toBe(1);
expect(loaded[0]?.id).toBe("sq_1");
await store.save([
{ ...entry, id: "sq_2" },
{ ...entry, id: "sq_3" },
]);
expect((await store.load()).length).toBe(2);
await store.save([]);
expect(await store.load()).toEqual([]);
});
it("creates the object store on first open", {
timeout: 5000,
}, async () => {
const store = new IndexedDbStore({
factory,
databaseName: "fresh_db",
objectStoreName: "fresh_queue",
});
await store.save([entry]);
expect((await store.load()).length).toBe(1);
});
it("throws when no factory is available", { timeout: 5000 }, () => {
const original = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
(globalThis as { indexedDB?: IDBFactory }).indexedDB = undefined;
expect(() => new IndexedDbStore()).toThrow();
(globalThis as { indexedDB?: IDBFactory }).indexedDB = original;
});
it("honors a custom database + store name", { timeout: 5000 }, async () => {
const store = new IndexedDbStore({
factory,
databaseName: "renamed_db",
objectStoreName: "renamed_queue",
});
await store.save([entry]);
expect((await store.load()).length).toBe(1);
});
it("uses globalThis.indexedDB when none injected", {
timeout: 5000,
}, async () => {
const original = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
(globalThis as { indexedDB?: IDBFactory }).indexedDB = newFactory();
const store = new IndexedDbStore({ databaseName: "global_db" });
await store.save([entry]);
expect((await store.load()).length).toBe(1);
(globalThis as { indexedDB?: IDBFactory }).indexedDB = original;
});
it("defaults databaseName and objectStoreName when neither is supplied", {
timeout: 5000,
}, async () => {
// Exercises the `?? DEFAULT_IDB_DATABASE` and `?? DEFAULT_IDB_OBJECT_STORE`
// falsy arms by passing only the factory.
const store = new IndexedDbStore({ factory });
await store.save([entry]);
expect((await store.load()).length).toBe(1);
});
it("rejects when the underlying open request raises onerror", {
timeout: 5000,
}, async () => {
const failingFactory: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: new DOMException("boom", "QuotaExceededError"),
result: null as unknown as IDBDatabase,
};
queueMicrotask(() => {
req.onerror?.(new Event("error"));
});
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: failingFactory,
databaseName: "err_open",
});
await expect(store.load()).rejects.toBeInstanceOf(DOMException);
});
it("rejects when the underlying open request raises onblocked", {
timeout: 5000,
}, async () => {
const blockedFactory: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
queueMicrotask(() => {
req.onblocked?.(new Event("blocked"));
});
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: blockedFactory,
databaseName: "blocked_open",
});
await expect(store.load()).rejects.toThrow(
/blocked by another connection/
);
});
it("rejects when the load (getAll) request raises onerror", {
timeout: 5000,
}, async () => {
// Hand-rolled minimal db whose getAll request fires onerror.
const factoryGetAllErr: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
const fakeDb = {
transaction() {
return {
objectStore() {
return {
getAll() {
const r = {
onsuccess: null as
| ((event: Event) => void)
| null,
onerror: null as
| ((event: Event) => void)
| null,
error: new DOMException(
"getAll boom",
"UnknownError"
),
result: undefined,
};
queueMicrotask(() => {
r.onerror?.(new Event("error"));
});
return r;
},
};
},
};
},
close() {},
};
Object.defineProperty(req, "result", {
value: fakeDb,
configurable: true,
});
queueMicrotask(() => req.onsuccess?.(new Event("success")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryGetAllErr,
databaseName: "getall_error",
});
await expect(store.load()).rejects.toBeInstanceOf(DOMException);
});
it("falls back to a synthetic Error when getAll error is null", {
timeout: 5000,
}, async () => {
const factoryGetAllNull: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
const fakeDb = {
transaction() {
return {
objectStore() {
return {
getAll() {
const r = {
onsuccess: null as
| ((event: Event) => void)
| null,
onerror: null as
| ((event: Event) => void)
| null,
error: null,
result: undefined,
};
queueMicrotask(() => {
r.onerror?.(new Event("error"));
});
return r;
},
};
},
};
},
close() {},
};
Object.defineProperty(req, "result", {
value: fakeDb,
configurable: true,
});
queueMicrotask(() => req.onsuccess?.(new Event("success")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryGetAllNull,
databaseName: "getall_null",
});
await expect(store.load()).rejects.toThrow(/idb getAll failed/);
});
it("rejects when the save transaction raises onerror", {
timeout: 5000,
}, async () => {
// Hand-rolled minimal db whose transaction fires onerror.
const factoryError: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
const fakeDb = {
transaction() {
const tx = {
oncomplete: null as ((event: Event) => void) | null,
onerror: null as ((event: Event) => void) | null,
onabort: null as ((event: Event) => void) | null,
error: new DOMException(
"tx failed",
"TransactionInactiveError"
),
objectStore() {
return {
clear() {},
put() {},
};
},
};
queueMicrotask(() => {
tx.onerror?.(new Event("error"));
});
return tx;
},
close() {},
};
Object.defineProperty(req, "result", {
value: fakeDb,
configurable: true,
});
queueMicrotask(() => req.onsuccess?.(new Event("success")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryError,
databaseName: "tx_error",
});
await expect(store.save([entry])).rejects.toBeInstanceOf(DOMException);
});
it("rejects when the save transaction aborts", {
timeout: 5000,
}, async () => {
const factoryAbort: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
const fakeDb = {
transaction() {
const tx = {
oncomplete: null as ((event: Event) => void) | null,
onerror: null as ((event: Event) => void) | null,
onabort: null as ((event: Event) => void) | null,
// null error → store falls back to its own Error.
error: null,
objectStore() {
return {
clear() {},
put() {},
};
},
};
queueMicrotask(() => {
tx.onabort?.(new Event("abort"));
});
return tx;
},
close() {},
};
Object.defineProperty(req, "result", {
value: fakeDb,
configurable: true,
});
queueMicrotask(() => req.onsuccess?.(new Event("success")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryAbort,
databaseName: "tx_abort",
});
await expect(store.save([entry])).rejects.toThrow(/aborted/);
});
it("falls back to a synthetic Error when save error is null", {
timeout: 5000,
}, async () => {
const factoryNullSave: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
const fakeDb = {
transaction() {
const tx = {
oncomplete: null as ((event: Event) => void) | null,
onerror: null as ((event: Event) => void) | null,
onabort: null as ((event: Event) => void) | null,
error: null,
objectStore() {
return {
clear() {},
put() {},
};
},
};
queueMicrotask(() => tx.onerror?.(new Event("error")));
return tx;
},
close() {},
};
Object.defineProperty(req, "result", {
value: fakeDb,
configurable: true,
});
queueMicrotask(() => req.onsuccess?.(new Event("success")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryNullSave,
databaseName: "save_null_err",
});
await expect(store.save([entry])).rejects.toThrow(/idb save failed/);
});
it("falls back to a synthetic Error when open error has no DOMException", {
timeout: 5000,
}, async () => {
const factoryNullErr: IDBFactory = {
open() {
const req = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onblocked: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
error: null,
result: null as unknown as IDBDatabase,
};
queueMicrotask(() => req.onerror?.(new Event("error")));
return req as unknown as IDBOpenDBRequest;
},
} as unknown as IDBFactory;
const store = new IndexedDbStore({
factory: factoryNullErr,
databaseName: "null_err",
});
await expect(store.load()).rejects.toThrow(/idb open failed/);
});
});

View File

@@ -0,0 +1,106 @@
import {
DEFAULT_IDB_DATABASE,
DEFAULT_IDB_OBJECT_STORE,
} from "../constants/index";
import type { SyncQueueEntry } from "../types/common";
import type { SyncQueueStore } from "./sync-queue.types";
/** Options consumed by {@link IndexedDbStore}. */
export interface IndexedDbStoreOptions {
/** `IDBFactory` seam. Defaults to `globalThis.indexedDB`. */
factory?: IDBFactory;
/** Database name. Defaults to `__suaveplan_pwa__`. */
databaseName?: string;
/** Object-store name. Defaults to `sync_queue`. */
objectStoreName?: string;
}
/**
* IndexedDB-backed `SyncQueueStore`. Default production store: scales beyond
* the 5 MiB `localStorage` budget and lets large request bodies persist
* without blocking the main thread on JSON serialisation.
*/
export class IndexedDbStore implements SyncQueueStore {
private readonly _factory: IDBFactory;
private readonly _databaseName: string;
private readonly _objectStoreName: string;
public constructor(options: IndexedDbStoreOptions = {}) {
const candidate =
options.factory ??
(globalThis as { indexedDB?: IDBFactory }).indexedDB;
if (candidate === undefined) {
throw new Error(
"IndexedDbStore requires an IDBFactory; none was supplied and globalThis.indexedDB is undefined"
);
}
this._factory = candidate;
this._databaseName = options.databaseName ?? DEFAULT_IDB_DATABASE;
this._objectStoreName =
options.objectStoreName ?? DEFAULT_IDB_OBJECT_STORE;
}
public async load(): Promise<Record<string, unknown>[]> {
const db = await this._open();
try {
return await new Promise<Record<string, unknown>[]>(
(resolve, reject) => {
const tx = db.transaction(
this._objectStoreName,
"readonly"
);
const store = tx.objectStore(this._objectStoreName);
const req = store.getAll();
req.onsuccess = () => {
const result = req.result as Record<string, unknown>[];
resolve(result);
};
req.onerror = () =>
reject(req.error ?? new Error("idb getAll failed"));
}
);
} finally {
db.close();
}
}
public async save(entries: readonly SyncQueueEntry[]): Promise<void> {
const db = await this._open();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(this._objectStoreName, "readwrite");
const store = tx.objectStore(this._objectStoreName);
store.clear();
for (const entry of entries) {
store.put(entry);
}
tx.oncomplete = () => resolve();
tx.onerror = () =>
reject(tx.error ?? new Error("idb save failed"));
tx.onabort = () =>
reject(tx.error ?? new Error("idb save aborted"));
});
} finally {
db.close();
}
}
private async _open(): Promise<IDBDatabase> {
return await new Promise<IDBDatabase>((resolve, reject) => {
const req = this._factory.open(this._databaseName, 1);
req.onupgradeneeded = () => {
// `onupgradeneeded` only fires when the requested version is
// higher than the on-disk version; at v1 fresh installs there
// is no existing object store to clash with.
req.result.createObjectStore(this._objectStoreName, {
keyPath: "id",
});
};
req.onsuccess = () => resolve(req.result);
req.onerror = () =>
reject(req.error ?? new Error("idb open failed"));
req.onblocked = () =>
reject(new Error("idb open blocked by another connection"));
});
}
}

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,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import type { StorageLike, SyncQueueEntry } from "../types/common";
import { createMemoryStorage, LocalStorageStore } from "./local-storage-store";
function makeStorage(): StorageLike {
const data = new Map<string, string>();
return {
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => {
data.set(k, v);
},
removeItem: (k) => {
data.delete(k);
},
};
}
const entry: SyncQueueEntry = {
id: "sq_1",
url: "/api/x",
method: "POST",
createdAt: 1,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
describe("LocalStorageStore", () => {
it("passes conformance harness", { timeout: 5000 }, async () => {
const store = new LocalStorageStore({ storage: makeStorage() });
expect(await store.load()).toEqual([]);
await store.save([entry]);
expect((await store.load()).length).toBe(1);
await store.save([entry, { ...entry, id: "sq_2" }]);
expect((await store.load()).length).toBe(2);
await store.save([]);
expect(await store.load()).toEqual([]);
});
it("honors a custom key", { timeout: 5000 }, async () => {
const storage = makeStorage();
const store = new LocalStorageStore({
storage,
key: "__custom__",
});
await store.save([entry]);
expect(storage.getItem("__custom__")).not.toBeNull();
});
it("tolerates corrupt JSON", { timeout: 5000 }, async () => {
const storage = makeStorage();
storage.setItem("__pwa_sync_queue__", "not-json{");
const store = new LocalStorageStore({ storage });
expect(await store.load()).toEqual([]);
});
it("treats non-array payloads as empty", { timeout: 5000 }, async () => {
const storage = makeStorage();
storage.setItem("__pwa_sync_queue__", "42");
const store = new LocalStorageStore({ storage });
expect(await store.load()).toEqual([]);
});
it("falls back to in-module MemoryStorage when nothing is available", {
timeout: 5000,
}, async () => {
const original = (globalThis as { localStorage?: StorageLike })
.localStorage;
(globalThis as { localStorage?: StorageLike }).localStorage = undefined;
const store = new LocalStorageStore();
expect(await store.load()).toEqual([]);
await store.save([entry]);
expect((await store.load()).length).toBe(1);
(globalThis as { localStorage?: StorageLike }).localStorage = original;
});
it("createMemoryStorage round-trips set / get / remove", {
timeout: 5000,
}, () => {
const mem = createMemoryStorage();
expect(mem.getItem("k")).toBeNull();
mem.setItem("k", "v");
expect(mem.getItem("k")).toBe("v");
mem.removeItem("k");
expect(mem.getItem("k")).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
import { SYNC_QUEUE_STORAGE_KEY } from "../constants/index";
import type { StorageLike, SyncQueueEntry } from "../types/common";
import type { SyncQueueStore } from "./sync-queue.types";
/** Options consumed by {@link LocalStorageStore}. */
export interface LocalStorageStoreOptions {
/** Storage seam. Defaults to `globalThis.localStorage`. */
storage?: StorageLike;
/** Storage key. Defaults to `__pwa_sync_queue__`. */
key?: string;
}
/**
* `localStorage`-backed `SyncQueueStore`. Retained for backwards compatibility
* with the upstream `@suaveplan/pwa@1.0.4` storage layer. New deployments
* should prefer `IndexedDbStore` for larger payloads and async persistence.
*/
export class LocalStorageStore implements SyncQueueStore {
private readonly _storage: StorageLike;
private readonly _key: string;
public constructor(options: LocalStorageStoreOptions = {}) {
this._storage =
options.storage ??
(globalThis as { localStorage?: StorageLike }).localStorage ??
createMemoryStorage();
this._key = options.key ?? SYNC_QUEUE_STORAGE_KEY;
}
public async load(): Promise<Record<string, unknown>[]> {
const raw = this._storage.getItem(this._key);
if (raw === null) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? (parsed as Record<string, unknown>[])
: [];
} catch {
return [];
}
}
public async save(entries: readonly SyncQueueEntry[]): Promise<void> {
this._storage.setItem(this._key, JSON.stringify(entries));
}
}
/**
* Inline `StorageLike` factory used as the last-resort fallback when neither
* `options.storage` nor `globalThis.localStorage` is available (e.g. workers
* or non-browser runtimes). The returned object retains data for the lifetime
* of the `LocalStorageStore` instance only — it is not shared across stores.
*/
export function createMemoryStorage(): StorageLike {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
data.set(key, value);
},
removeItem: (key) => {
data.delete(key);
},
};
}

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,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import type { SyncQueueEntry } from "../types/common";
import { MemoryStore } from "./memory-store";
const entry: SyncQueueEntry = {
id: "sq_1",
url: "/api/x",
method: "POST",
createdAt: 1,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
describe("MemoryStore", () => {
it("passes conformance harness", { timeout: 5000 }, async () => {
const store = new MemoryStore();
// initial load empty
expect(await store.load()).toEqual([]);
// save round-trip
await store.save([entry]);
const loaded = await store.load();
expect(loaded.length).toBe(1);
expect(loaded[0]?.id).toBe("sq_1");
// overwrite
await store.save([
{ ...entry, id: "sq_2" },
{ ...entry, id: "sq_3" },
]);
expect((await store.load()).length).toBe(2);
// clear via save empty
await store.save([]);
expect(await store.load()).toEqual([]);
});
it("accepts a seed", { timeout: 5000 }, async () => {
const store = new MemoryStore([entry]);
const loaded = await store.load();
expect(loaded.length).toBe(1);
});
it("returns defensive copies", { timeout: 5000 }, async () => {
const store = new MemoryStore([entry]);
const loaded = await store.load();
(loaded[0] as Record<string, unknown>).url = "/tampered";
const reloaded = await store.load();
expect(reloaded[0]?.url).toBe("/api/x");
});
});

View File

@@ -0,0 +1,22 @@
import type { SyncQueueEntry } from "../types/common";
import type { SyncQueueStore } from "./sync-queue.types";
/**
* In-memory `SyncQueueStore`. Useful as the default for unit tests and for
* runtimes (Service Workers, Node) that have no native persistence available.
*/
export class MemoryStore implements SyncQueueStore {
private _entries: SyncQueueEntry[] = [];
public constructor(seed: readonly SyncQueueEntry[] = []) {
this._entries = [...seed];
}
public async load(): Promise<Record<string, unknown>[]> {
return this._entries.map((e) => ({ ...e }));
}
public async save(entries: readonly SyncQueueEntry[]): Promise<void> {
this._entries = entries.map((e) => ({ ...e }));
}
}

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,460 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { PWA_CODES, PwaError } from "../errors/errors";
import type { Clock, SyncQueueEntry } from "../types/common";
import { MemoryStore } from "./memory-store";
import { SyncQueue } from "./sync-queue";
function makeClock(start = 1_700_000_000_000): Clock {
let t = start;
return {
now() {
const v = t;
t += 1;
return v;
},
};
}
let nextId: number;
const idGen = (): string => `sq_${++nextId}`;
beforeEach(() => {
nextId = 0;
});
describe("SyncQueue.enqueue", () => {
it("validates and persists a new entry", { timeout: 5000 }, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
});
const entry = await queue.enqueue("/api/x", "POST", {
headers: { "x-trace": "abc" },
body: '{"a":1}',
metadata: { tenant: "acme" },
});
expect(entry.id).toBe("sq_1");
expect(entry.url).toBe("/api/x");
expect(entry.method).toBe("POST");
expect(entry.schemaVersion).toBe(2);
expect(await queue.size()).toBe(1);
});
it("raises SYNC_QUEUE_FULL when at capacity", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
maxSize: 1,
});
await queue.enqueue("/a", "GET");
await expect(queue.enqueue("/b", "GET")).rejects.toBeInstanceOf(
PwaError
);
try {
await queue.enqueue("/b", "GET");
} catch (e) {
expect((e as PwaError).code).toBe(PWA_CODES.SYNC_QUEUE_FULL);
}
expect(await queue.size()).toBe(1);
});
it("raises SYNC_QUEUE_VALIDATION for malformed metadata", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
});
try {
await queue.enqueue("/x", "POST", {
metadata: { fn: (() => 1) as unknown as string },
});
throw new Error("did not throw");
} catch (e) {
expect(e).toBeInstanceOf(PwaError);
expect((e as PwaError).code).toBe(PWA_CODES.SYNC_QUEUE_VALIDATION);
}
});
});
describe("SyncQueue.process", () => {
it("processes successful entries and removes them", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
onProcess: async () => true,
});
await queue.enqueue("/a", "POST");
await queue.enqueue("/b", "POST");
const processed = await queue.process();
expect(processed).toBe(2);
expect(await queue.size()).toBe(0);
});
it("requeues entries below maxRetries and invokes onFailure at the cap", {
timeout: 5000,
}, async () => {
const failures: SyncQueueEntry[] = [];
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
maxRetries: 2,
onProcess: async () => false,
onFailure: (e) => failures.push(e),
});
await queue.enqueue("/fail", "POST");
expect(await queue.process()).toBe(0);
let entries = await queue.getAll();
expect(entries.length).toBe(1);
expect(entries[0]?.retryCount).toBe(1);
expect(failures.length).toBe(0);
expect(await queue.process()).toBe(0);
entries = await queue.getAll();
expect(entries.length).toBe(0);
expect(failures.length).toBe(1);
});
it("thrown errors count as failures and increment retryCount", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
maxRetries: 5,
onProcess: async () => {
throw new Error("boom");
},
});
await queue.enqueue("/x", "POST");
await queue.process();
const entries = await queue.getAll();
expect(entries[0]?.retryCount).toBe(1);
});
it("is single-flight", { timeout: 5000 }, async () => {
let calls = 0;
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
onProcess: async () => {
calls += 1;
await new Promise((r) => setTimeout(r, 1));
return true;
},
});
await queue.enqueue("/x", "POST");
const [a, b] = await Promise.all([queue.process(), queue.process()]);
expect(a + b).toBe(1);
expect(calls).toBe(1);
});
it("returns 0 when queue is empty", { timeout: 5000 }, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
onProcess: async () => true,
});
expect(await queue.process()).toBe(0);
});
it("default fetch processor handles missing fetch", {
timeout: 5000,
}, async () => {
const original = (globalThis as { fetch?: typeof fetch }).fetch;
(globalThis as { fetch?: typeof fetch }).fetch = undefined;
try {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
maxRetries: 1,
});
await queue.enqueue("/x", "POST");
await queue.process();
expect(await queue.size()).toBe(0);
} finally {
(globalThis as { fetch?: typeof fetch }).fetch = original;
}
});
it("default fetch processor uses globalThis.fetch when present", {
timeout: 5000,
}, async () => {
const original = (globalThis as { fetch?: typeof fetch }).fetch;
let called = false;
(globalThis as { fetch?: typeof fetch }).fetch = (async (
_url: RequestInfo,
_init?: RequestInit
) => {
called = true;
return new Response("ok", { status: 200 });
}) as typeof fetch;
try {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
});
await queue.enqueue("/x", "POST", {
body: "x",
headers: { "x-y": "1" },
});
expect(await queue.process()).toBe(1);
expect(called).toBe(true);
} finally {
(globalThis as { fetch?: typeof fetch }).fetch = original;
}
});
});
describe("SyncQueue lifecycle", () => {
it("remove() returns false when id is absent", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
idGenerator: idGen,
});
expect(await queue.remove("missing")).toBe(false);
});
it("remove() removes a known entry", { timeout: 5000 }, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
idGenerator: idGen,
});
const e = await queue.enqueue("/x", "GET");
expect(await queue.remove(e.id)).toBe(true);
expect(await queue.size()).toBe(0);
});
it("clear() empties the queue", { timeout: 5000 }, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
idGenerator: idGen,
});
await queue.enqueue("/x", "GET");
await queue.enqueue("/y", "GET");
await queue.clear();
expect(await queue.size()).toBe(0);
});
it("isProcessing flag flips during a flush", {
timeout: 5000,
}, async () => {
const queue = new SyncQueue({
store: new MemoryStore(),
idGenerator: idGen,
onProcess: async () => {
expect(queue.isProcessing).toBe(true);
return true;
},
});
await queue.enqueue("/x", "GET");
expect(queue.isProcessing).toBe(false);
await queue.process();
expect(queue.isProcessing).toBe(false);
});
it("retryDelayFor applies exponential backoff", { timeout: 5000 }, () => {
const queue = new SyncQueue({
store: new MemoryStore(),
idGenerator: idGen,
retryDelay: 100,
retryBackoffFactor: 3,
});
expect(queue.retryDelayFor(0)).toBe(100);
expect(queue.retryDelayFor(1)).toBe(300);
expect(queue.retryDelayFor(2)).toBe(900);
});
it("migrates legacy entries from the store", {
timeout: 5000,
}, async () => {
const store = new MemoryStore();
// bypass the queue and seed legacy v1 raw rows directly
(store as unknown as { _entries: Record<string, unknown>[] })._entries =
[
{
id: "legacy",
url: "/old",
method: "GET",
createdAt: 0,
},
];
const queue = new SyncQueue({
store,
idGenerator: idGen,
});
const all = await queue.getAll();
expect(all.length).toBe(1);
expect(all[0]?.schemaVersion).toBe(2);
});
it("drops entries that fail validation even after migration", {
timeout: 5000,
}, async () => {
const store = new MemoryStore();
(store as unknown as { _entries: Record<string, unknown>[] })._entries =
[
{
// missing required url
id: "bad",
method: "GET",
createdAt: 0,
},
];
const queue = new SyncQueue({
store,
idGenerator: idGen,
});
const all = await queue.getAll();
expect(all.length).toBe(0);
});
it("threads an injected observability bundle through without reconstructing it", {
timeout: 5000,
}, async () => {
const { createPwaObservability } = await import(
"../observability/observability"
);
const observability = createPwaObservability();
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
observability,
});
await queue.enqueue("/a", "GET");
expect(await queue.size()).toBe(1);
});
it("falls back to globalThis.fetch when no onProcess is injected", {
timeout: 5000,
}, async () => {
const originalFetch = (globalThis as { fetch?: typeof fetch }).fetch;
const calls: { url: string; init: RequestInit | undefined }[] = [];
(globalThis as { fetch?: typeof fetch }).fetch = ((
input: string,
init?: RequestInit
) => {
calls.push({ url: String(input), init });
return Promise.resolve(new Response("ok", { status: 200 }));
}) as unknown as typeof fetch;
try {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
});
await queue.enqueue("/api/default-process", "POST", {
headers: { "x-test": "y" },
body: '{"x":1}',
});
const processed = await queue.process();
expect(processed).toBe(1);
expect(calls.length).toBe(1);
expect(calls[0]?.url).toBe("/api/default-process");
expect((calls[0]?.init as RequestInit | undefined)?.method).toBe(
"POST"
);
expect((calls[0]?.init as RequestInit | undefined)?.body).toBe(
'{"x":1}'
);
} finally {
if (originalFetch === undefined) {
Reflect.deleteProperty(globalThis as object, "fetch");
} else {
(globalThis as { fetch?: typeof fetch }).fetch = originalFetch;
}
}
});
it("defaultProcess omits headers / body when the entry has none", {
timeout: 5000,
}, async () => {
const originalFetch = (globalThis as { fetch?: typeof fetch }).fetch;
const calls: { init: RequestInit | undefined }[] = [];
(globalThis as { fetch?: typeof fetch }).fetch = ((
_input: string,
init?: RequestInit
) => {
calls.push({ init });
return Promise.resolve(new Response("ok"));
}) as unknown as typeof fetch;
try {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
});
await queue.enqueue("/api/bare", "GET");
await queue.process();
expect(calls.length).toBe(1);
expect(calls[0]?.init?.headers).toBeUndefined();
expect(calls[0]?.init?.body).toBeUndefined();
expect(calls[0]?.init?.method).toBe("GET");
} finally {
if (originalFetch === undefined) {
Reflect.deleteProperty(globalThis as object, "fetch");
} else {
(globalThis as { fetch?: typeof fetch }).fetch = originalFetch;
}
}
});
it("returns false from defaultProcess when fetch is undefined", {
timeout: 5000,
}, async () => {
const originalFetch = (globalThis as { fetch?: typeof fetch }).fetch;
Reflect.deleteProperty(globalThis as object, "fetch");
try {
const queue = new SyncQueue({
store: new MemoryStore(),
clock: makeClock(),
idGenerator: idGen,
maxRetries: 1,
});
await queue.enqueue("/api/no-fetch", "GET");
// defaultProcess returns false → retryCount increments to 1 ===
// maxRetries → entry is dropped via the failure branch.
await queue.process();
expect(await queue.size()).toBe(0);
} finally {
if (originalFetch !== undefined) {
(globalThis as { fetch?: typeof fetch }).fetch = originalFetch;
}
}
});
it("uses the built-in default id generator when none is injected", {
timeout: 5000,
}, async () => {
const clock = makeClock(42);
const queue = new SyncQueue({
store: new MemoryStore(),
clock,
});
const a = await queue.enqueue("/api/a", "POST");
const b = await queue.enqueue("/api/b", "POST");
// defaultIdGenerator format: `sq_<clock.now()>_<counter>`
expect(a.id.startsWith("sq_")).toBe(true);
expect(b.id.startsWith("sq_")).toBe(true);
// Counter increments monotonically across calls
expect(a.id.endsWith("_1")).toBe(true);
expect(b.id.endsWith("_2")).toBe(true);
expect(a.id).not.toBe(b.id);
});
});

View File

@@ -0,0 +1,219 @@
import {
DEFAULT_MAX_QUEUE_SIZE,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_FACTOR,
DEFAULT_RETRY_DELAY,
SYNC_QUEUE_SCHEMA_VERSION,
} from "../constants/index";
import { PWA_CODES, PwaError } from "../errors/errors";
import { createPwaObservability } from "../observability/observability";
import type { PwaObservability } from "../observability/observability.types";
import { SyncQueueEntrySchema } from "../schemas/schemas";
import { type Clock, SYSTEM_CLOCK, type SyncQueueEntry } from "../types/common";
import type {
IdGenerator,
ISyncQueue,
SyncQueueOptions,
SyncQueueStore,
} from "./sync-queue.types";
import {
asSyncQueueEntry,
DEFAULT_MIGRATORS,
migrateEntry,
type VersionMigrator,
} from "./version-migration";
function defaultIdGenerator(clock: Clock): IdGenerator {
let counter = 0;
return () => `sq_${String(clock.now())}_${String(++counter)}`;
}
async function defaultProcess(entry: SyncQueueEntry): Promise<boolean> {
if (typeof fetch === "undefined") return false;
const init: RequestInit = { method: entry.method };
if (entry.headers !== undefined) init.headers = entry.headers;
if (entry.body !== undefined) init.body = entry.body;
const response = await fetch(entry.url, init);
return response.ok;
}
/**
* Offline-first sync queue. Persists requests through a pluggable
* {@link SyncQueueStore} backend, validates entries via Zod, supports
* exponential-backoff retry, and emits OTel counters / histograms.
*/
export class SyncQueue implements ISyncQueue {
private readonly _store: SyncQueueStore;
private readonly _clock: Clock;
private readonly _idGenerator: IdGenerator;
private readonly _migrators: readonly VersionMigrator[];
private readonly _observability: PwaObservability;
private readonly _maxSize: number;
private readonly _maxRetries: number;
private readonly _retryDelay: number;
private readonly _retryBackoffFactor: number;
private readonly _onProcess?: (entry: SyncQueueEntry) => Promise<boolean>;
private readonly _onFailure?: (entry: SyncQueueEntry) => void;
private _processing = false;
public constructor(options: SyncQueueOptions) {
this._store = options.store;
this._clock = options.clock ?? SYSTEM_CLOCK;
this._idGenerator =
options.idGenerator ?? defaultIdGenerator(this._clock);
this._migrators = options.migrators ?? DEFAULT_MIGRATORS;
this._observability = createPwaObservability(
options.observability !== undefined
? { observability: options.observability }
: undefined
);
this._maxSize = options.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
this._maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
this._retryDelay = options.retryDelay ?? DEFAULT_RETRY_DELAY;
this._retryBackoffFactor =
options.retryBackoffFactor ?? DEFAULT_RETRY_BACKOFF_FACTOR;
if (options.onProcess !== undefined)
this._onProcess = options.onProcess;
if (options.onFailure !== undefined)
this._onFailure = options.onFailure;
}
public get isProcessing(): boolean {
return this._processing;
}
/** Compute the retry delay for an entry's current retry count. */
public retryDelayFor(retryCount: number): number {
return Math.round(
this._retryDelay * this._retryBackoffFactor ** retryCount
);
}
public async enqueue(
url: string,
method: string,
options?: {
headers?: Record<string, string>;
body?: string;
metadata?: Record<string, unknown>;
}
): Promise<SyncQueueEntry> {
const existing = await this._loadEntries();
if (existing.length >= this._maxSize) {
throw new PwaError(
PWA_CODES.SYNC_QUEUE_FULL,
`Sync queue is at capacity (${String(this._maxSize)})`
);
}
const candidate: SyncQueueEntry = {
id: this._idGenerator(),
url,
method,
createdAt: this._clock.now(),
retryCount: 0,
maxRetries: this._maxRetries,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
...(options?.headers !== undefined
? { headers: options.headers }
: {}),
...(options?.body !== undefined ? { body: options.body } : {}),
...(options?.metadata !== undefined
? { metadata: options.metadata }
: {}),
};
const parsed = SyncQueueEntrySchema.safeParse(candidate);
if (!parsed.success) {
throw new PwaError(
PWA_CODES.SYNC_QUEUE_VALIDATION,
`Sync queue entry failed validation: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
const next = [...existing, parsed.data];
await this._store.save(next);
this._observability.counters.syncEnqueuedTotal.add(1, {
method: method.toLowerCase(),
});
return parsed.data;
}
public async remove(id: string): Promise<boolean> {
const existing = await this._loadEntries();
const filtered = existing.filter((e) => e.id !== id);
if (filtered.length === existing.length) return false;
await this._store.save(filtered);
return true;
}
public async getAll(): Promise<SyncQueueEntry[]> {
return this._loadEntries();
}
public async size(): Promise<number> {
return (await this._loadEntries()).length;
}
public async clear(): Promise<void> {
await this._store.save([]);
}
public async process(): Promise<number> {
if (this._processing) return 0;
this._processing = true;
const startedAt = this._clock.now();
try {
const entries = await this._loadEntries();
if (entries.length === 0) return 0;
const toRemove: string[] = [];
const updated: SyncQueueEntry[] = [];
let processed = 0;
for (const entry of entries) {
let success = false;
try {
success = await (this._onProcess ?? defaultProcess)(entry);
} catch {
success = false;
}
if (success) {
toRemove.push(entry.id);
processed += 1;
this._observability.counters.syncProcessedTotal.add(1);
} else {
const next: SyncQueueEntry = {
...entry,
retryCount: entry.retryCount + 1,
};
if (next.retryCount >= entry.maxRetries) {
toRemove.push(entry.id);
this._onFailure?.(entry);
this._observability.counters.syncFailedTotal.add(1);
} else {
updated.push(next);
}
}
}
const updatedById = new Map(updated.map((u) => [u.id, u]));
const survivors = entries
.filter((e) => !toRemove.includes(e.id))
.map((e) => updatedById.get(e.id) as SyncQueueEntry);
await this._store.save(survivors);
this._observability.histograms.syncFlushMs.record(
this._clock.now() - startedAt
);
return processed;
} finally {
this._processing = false;
}
}
private async _loadEntries(): Promise<SyncQueueEntry[]> {
const raws = await this._store.load();
const migrated = raws.map((r) => migrateEntry(r, this._migrators));
const out: SyncQueueEntry[] = [];
for (const m of migrated) {
const parsed = SyncQueueEntrySchema.safeParse(m);
if (parsed.success) out.push(asSyncQueueEntry(parsed.data));
}
return out;
}
}

View File

@@ -0,0 +1,47 @@
import type { PwaObservability } from "../observability/observability.types";
import type { Clock, SyncQueueConfig, SyncQueueEntry } from "../types/common";
import type { VersionMigrator } from "./version-migration";
/** Pluggable persistence backend for {@link ISyncQueue}. */
export interface SyncQueueStore {
/** Load every persisted entry. Returns raw records (pre-migration). */
load(): Promise<Record<string, unknown>[]>;
/** Replace every persisted entry with the supplied snapshot. */
save(entries: readonly SyncQueueEntry[]): Promise<void>;
}
/** Random-ID generator seam used by `SyncQueue.enqueue`. */
export type IdGenerator = () => string;
/** Options consumed by `SyncQueue` constructor. */
export interface SyncQueueOptions extends SyncQueueConfig {
/** Persistence backend; required. */
store: SyncQueueStore;
/** Clock seam. Defaults to `Date.now`. */
clock?: Clock;
/** Id generator. Defaults to a monotonic `sq_<ms>_<n>`. */
idGenerator?: IdGenerator;
/** Migrator list. Defaults to `DEFAULT_MIGRATORS`. */
migrators?: readonly VersionMigrator[];
/** Optional observability bundle. */
observability?: PwaObservability;
}
/** Public surface of `SyncQueue`. */
export interface ISyncQueue {
enqueue(
url: string,
method: string,
options?: {
headers?: Record<string, string>;
body?: string;
metadata?: Record<string, unknown>;
}
): Promise<SyncQueueEntry>;
remove(id: string): Promise<boolean>;
getAll(): Promise<SyncQueueEntry[]>;
size(): Promise<number>;
clear(): Promise<void>;
process(): Promise<number>;
readonly isProcessing: boolean;
}

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,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import { SyncQueueEntrySchema } from "../schemas/schemas";
import {
asSyncQueueEntry,
migrateBatch,
migrateEntry,
type VersionMigrator,
} from "./version-migration";
describe("version-migration", () => {
it("upgrades v1 entries to current schema version", {
timeout: 5000,
}, () => {
const raw = {
id: "old",
url: "/api/x",
method: "POST",
createdAt: 0,
};
const upgraded = migrateEntry(raw);
expect(upgraded.schemaVersion).toBe(SYNC_QUEUE_SCHEMA_VERSION);
const parsed = SyncQueueEntrySchema.parse(upgraded);
expect(parsed.id).toBe("old");
expect(parsed.retryCount).toBe(0);
expect(parsed.maxRetries).toBe(3);
});
it("is idempotent on current-version entries", { timeout: 5000 }, () => {
const current = {
id: "x",
url: "/u",
method: "GET",
createdAt: 1,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
const out = migrateEntry(current);
expect(out).toEqual(current);
});
it("throws when no migrator is registered", { timeout: 5000 }, () => {
const raw = {
id: "x",
url: "/u",
method: "GET",
createdAt: 1,
schemaVersion: 0,
};
expect(() => migrateEntry(raw)).toThrow();
});
it("honors a caller-supplied migrator chain", { timeout: 5000 }, () => {
const v0ToV1: VersionMigrator = {
fromVersion: 0,
upgrade: (e) => ({ ...e, schemaVersion: 1, addedAtV1: true }),
};
const v1ToV2: VersionMigrator = {
fromVersion: 1,
upgrade: (e) => ({
...e,
schemaVersion: 2,
retryCount: 0,
maxRetries: 3,
}),
};
const raw = {
id: "x",
url: "/u",
method: "GET",
createdAt: 1,
schemaVersion: 0,
};
const out = migrateEntry(raw, [v0ToV1, v1ToV2]);
expect(out.schemaVersion).toBe(SYNC_QUEUE_SCHEMA_VERSION);
expect(out["addedAtV1"]).toBe(true);
});
it("migrateBatch processes arrays", { timeout: 5000 }, () => {
const raws = [
{ id: "a", url: "/", method: "GET", createdAt: 0 },
{ id: "b", url: "/", method: "GET", createdAt: 0 },
];
const out = migrateBatch(raws);
expect(out.length).toBe(2);
for (const e of out) {
expect(e.schemaVersion).toBe(SYNC_QUEUE_SCHEMA_VERSION);
}
});
it("preserves explicit retryCount and maxRetries when migrating v1 entries", {
timeout: 5000,
}, () => {
// v1 entry already carrying numeric retryCount + maxRetries exercises
// the `typeof rc === "number"` / `typeof mr === "number"` truthy
// branches of V1_TO_V2.upgrade (sibling test covers the falsy arms).
const raw = {
id: "v1-with-retries",
url: "/api/x",
method: "POST",
createdAt: 0,
retryCount: 7,
maxRetries: 42,
};
const upgraded = migrateEntry(raw);
expect(upgraded.retryCount).toBe(7);
expect(upgraded.maxRetries).toBe(42);
expect(upgraded.schemaVersion).toBe(SYNC_QUEUE_SCHEMA_VERSION);
});
it("asSyncQueueEntry narrows the type", { timeout: 5000 }, () => {
const raw = {
id: "x",
url: "/u",
method: "POST",
createdAt: 1,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
const entry = asSyncQueueEntry(raw);
expect(entry.id).toBe("x");
});
});

View File

@@ -0,0 +1,71 @@
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import type { SyncQueueEntry } from "../types/common";
/** A single forward migrator from `fromVersion` to `fromVersion + 1`. */
export interface VersionMigrator {
readonly fromVersion: number;
upgrade(entry: Record<string, unknown>): Record<string, unknown>;
}
/** v1 → v2: legacy entries lacked `schemaVersion`; assume retryCount/maxRetries defaults if absent. */
const V1_TO_V2: VersionMigrator = {
fromVersion: 1,
upgrade(entry) {
const rc = entry["retryCount"];
const mr = entry["maxRetries"];
return {
...entry,
retryCount: typeof rc === "number" ? rc : 0,
maxRetries: typeof mr === "number" ? mr : 3,
schemaVersion: 2,
};
},
};
/** Default migrator registry shipped with the package. */
export const DEFAULT_MIGRATORS: readonly VersionMigrator[] = Object.freeze([
V1_TO_V2,
]);
/**
* Forward-migrate a single raw entry record to the current schema version.
* Idempotent: entries already at the current version pass through unchanged.
*/
export function migrateEntry(
raw: Record<string, unknown>,
migrators: readonly VersionMigrator[] = DEFAULT_MIGRATORS
): Record<string, unknown> {
const startVersion =
typeof raw["schemaVersion"] === "number" ? raw["schemaVersion"] : 1;
let current: Record<string, unknown> = {
...raw,
schemaVersion: startVersion,
};
while (true) {
const v = current["schemaVersion"];
if (typeof v !== "number" || v >= SYNC_QUEUE_SCHEMA_VERSION) break;
const migrator = migrators.find((m) => m.fromVersion === v);
if (migrator === undefined) {
throw new Error(
`No sync-queue migrator registered for schemaVersion=${String(v)}`
);
}
current = migrator.upgrade(current);
}
return current;
}
/** Migrate a batch of raw entries. */
export function migrateBatch(
raws: readonly Record<string, unknown>[],
migrators: readonly VersionMigrator[] = DEFAULT_MIGRATORS
): Record<string, unknown>[] {
return raws.map((r) => migrateEntry(r, migrators));
}
/** Type-narrowing helper to cast a migrated record into a {@link SyncQueueEntry}. */
export function asSyncQueueEntry(
record: Record<string, unknown>
): SyncQueueEntry {
return record as unknown as SyncQueueEntry;
}

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,117 @@
/** Service worker lifecycle state observable from the manager. */
export type SwState =
| "installing"
| "installed"
| "activating"
| "activated"
| "redundant"
| "unsupported";
/** Service worker registration options. */
export interface SwRegistrationOptions {
/** Path to the service worker script. */
scriptUrl: string;
/** Registration scope. */
scope?: string;
/** Update polling interval in ms (`0` to disable). */
updateInterval?: number;
/** Called when a new worker is waiting to activate. */
onUpdate?: (registration: ServiceWorkerRegistration) => void;
/** Called when the worker activates for the first time. */
onSuccess?: (registration: ServiceWorkerRegistration) => void;
/** Called when registration fails. */
onError?: (error: Error) => void;
}
/** Result returned by `ServiceWorkerManager.register()`. */
export interface SwRegistrationResult {
success: boolean;
state: SwState;
error?: Error;
}
/**
* Sync queue entry shape (current schema version).
*
* Optional fields use the `T | undefined` form (rather than `T?:`) so callers
* working under `exactOptionalPropertyTypes` can still assign `undefined`
* explicitly without contortion. The Zod schema makes them genuinely optional
* at validation time.
*/
export interface SyncQueueEntry {
id: string;
url: string;
method: string;
headers?: Record<string, string> | undefined;
body?: string | undefined;
createdAt: number;
retryCount: number;
maxRetries: number;
metadata?: Record<string, unknown> | undefined;
schemaVersion: number;
}
/** Sync queue configuration. */
export interface SyncQueueConfig {
maxSize?: number;
maxRetries?: number;
retryDelay?: number;
retryBackoffFactor?: number;
onProcess?: (entry: SyncQueueEntry) => Promise<boolean>;
onFailure?: (entry: SyncQueueEntry) => void;
}
/** Push notification permission state. */
export type PushPermission = "granted" | "denied" | "default";
/** Push notification subscription result. */
export interface PushSubscriptionResult {
subscribed: boolean;
endpoint?: string;
error?: Error;
}
/** Push notification options. */
export interface PushNotificationOptions {
/** VAPID application server key (URL-safe base64). */
applicationServerKey?: string;
/** Whether the subscription must be user-visible. Defaults to `true`. */
userVisibleOnly?: boolean;
}
/** Install prompt state. */
export type InstallPromptState =
| "idle"
| "available"
| "accepted"
| "dismissed";
/** Install prompt state-change listener. */
export type InstallPromptListener = (state: InstallPromptState) => void;
/** Injectable clock contract — kept narrow to avoid a `@suaveplan/types` dep cycle. */
export interface Clock {
now(): number;
}
/** The system clock backed by `Date.now`. */
export const SYSTEM_CLOCK: Clock = { now: () => Date.now() };
/** Minimal `window`-shaped event target used by `InstallPromptManager`. */
export interface WindowLike {
addEventListener(type: string, listener: (event: Event) => void): void;
removeEventListener(type: string, listener: (event: Event) => void): void;
}
/** Minimal `Storage`-shaped interface used by the LocalStorage store. */
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Minimal `Notification`-static contract consumed by `PushNotificationManager`. */
export interface NotificationStaticLike {
readonly permission: PushPermission;
requestPermission(): Promise<PushPermission>;
}

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.

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BroadcastChannelLike,
UpdateController,
} from "./update-controller";
class HubChannel implements BroadcastChannelLike {
public listeners: ((event: MessageEvent) => void)[] = [];
public closed = false;
public name: string;
public constructor(
private readonly hub: Hub,
name: string
) {
this.name = name;
hub.channels.push(this);
}
public postMessage(message: unknown): void {
for (const ch of this.hub.channels) {
if (ch === this || ch.closed) continue;
for (const l of ch.listeners) {
l({ data: message } as MessageEvent);
}
}
}
public close(): void {
this.closed = true;
}
public addEventListener(
_type: "message",
listener: (event: MessageEvent) => void
): void {
this.listeners.push(listener);
}
public removeEventListener(
_type: "message",
listener: (event: MessageEvent) => void
): void {
this.listeners = this.listeners.filter((l) => l !== listener);
}
}
class Hub {
public channels: HubChannel[] = [];
public open(name: string): HubChannel {
return new HubChannel(this, name);
}
}
describe("UpdateController", () => {
it("broadcasts reload and triggers reload on receivers", {
timeout: 5000,
}, () => {
const hub = new Hub();
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();
expect(reloaded.b).toBe(1);
expect(reloaded.a).toBe(0);
a.close();
b.close();
});
it("ignores unrelated messages", { timeout: 5000 }, () => {
const hub = new Hub();
const reloaded = { v: 0 };
const a = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
window: {
location: {
reload: () => {
reloaded.v++;
},
},
},
});
const b = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
window: {
location: {
reload: () => {
reloaded.v++;
},
},
},
});
// bypass the typed signal to inject a foreign message via the second controller
(
b as unknown as {
_channel: { postMessage: (m: unknown) => void };
}
)._channel.postMessage({
type: "SOMETHING_ELSE",
});
expect(reloaded.v).toBe(0);
a.close();
b.close();
});
it("close() stops further receipt", { timeout: 5000 }, () => {
const hub = new Hub();
const reloaded = { v: 0 };
const a = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
window: {
location: {
reload: () => {
reloaded.v++;
},
},
},
});
const b = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
window: {
location: {
reload: () => {
reloaded.v++;
},
},
},
});
b.close();
a.signalReload();
expect(reloaded.v).toBe(0);
a.close();
});
it("constructs with default factory and window seams in happy-dom", {
timeout: 5000,
}, () => {
const ctrl = new UpdateController();
expect(() => ctrl.close()).not.toThrow();
});
it("resolves window to undefined when globalThis.location is absent", {
timeout: 5000,
}, () => {
const hub = new Hub();
const original = (globalThis as { location?: unknown }).location;
Reflect.deleteProperty(globalThis as object, "location");
try {
const ctrl = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
});
// No window means RELOAD receipt is a silent no-op — must not throw.
const peer = new UpdateController({
broadcastChannelFactory: (n) => hub.open(n),
window: {
location: { reload: () => {} },
},
});
expect(() => peer.signalReload()).not.toThrow();
ctrl.close();
peer.close();
} finally {
if (original !== undefined) {
(globalThis as { location?: unknown }).location = original;
}
}
});
});

View File

@@ -0,0 +1,87 @@
import { UPDATE_CHANNEL_NAME } from "../constants/index";
/** Message payload broadcast across tabs when a new SW activates. */
export interface UpdateMessage {
readonly type: "RELOAD";
}
/** Minimal `Window`-shaped seam used by {@link UpdateController}. */
export interface UpdateWindowLike {
readonly location: { reload: () => void };
}
/** Minimal `BroadcastChannel`-shaped seam. */
export interface BroadcastChannelLike {
postMessage(message: unknown): void;
close(): void;
addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void;
removeEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void;
}
/** Factory producing a {@link BroadcastChannelLike} for a given channel name. */
export type BroadcastChannelFactory = (name: string) => BroadcastChannelLike;
/** Options consumed by {@link UpdateController}. */
export interface UpdateControllerOptions {
/** Channel name; defaults to `@suaveplan/pwa/update`. */
channelName?: string;
/** BroadcastChannel factory. Defaults to `globalThis.BroadcastChannel`. */
broadcastChannelFactory?: BroadcastChannelFactory;
/** Window seam. Defaults to `globalThis.window`. */
window?: UpdateWindowLike;
}
const DEFAULT_BC_FACTORY: BroadcastChannelFactory = (name) =>
new BroadcastChannel(name) as unknown as BroadcastChannelLike;
/** Returns the platform `window` (with `location.reload`) when one exists. */
function resolveDefaultWindow(): UpdateWindowLike | undefined {
const candidate = globalThis as {
location?: { reload?: () => void };
};
if (typeof candidate.location === "undefined") return undefined;
return globalThis as unknown as UpdateWindowLike;
}
/**
* Coordinates a cross-tab page reload when a new service worker activates.
* Each tab opens a `BroadcastChannel` listening for `{ type: "RELOAD" }`;
* `signalReload()` broadcasts the message, and receivers invoke
* `window.location.reload()`.
*/
export class UpdateController {
private readonly _channel: BroadcastChannelLike;
private readonly _window: UpdateWindowLike | undefined;
private readonly _listener: (event: MessageEvent) => void;
public constructor(options: UpdateControllerOptions = {}) {
const factory = options.broadcastChannelFactory ?? DEFAULT_BC_FACTORY;
this._channel = factory(options.channelName ?? UPDATE_CHANNEL_NAME);
this._window = options.window ?? resolveDefaultWindow();
this._listener = (event: MessageEvent) => {
const data = event.data as UpdateMessage | undefined;
if (data?.type === "RELOAD") {
this._window?.location.reload();
}
};
this._channel.addEventListener("message", this._listener);
}
/** Broadcast a reload signal to every other tab. */
public signalReload(): void {
const msg: UpdateMessage = { type: "RELOAD" };
this._channel.postMessage(msg);
}
/** Tear down listeners and close the channel. */
public close(): void {
this._channel.removeEventListener("message", this._listener);
this._channel.close();
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,72 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/pwa",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"errors/errors": "src/errors/errors.ts",
"schemas/schemas": "src/schemas/schemas.ts",
"observability/observability":
"src/observability/observability.ts",
"service-worker/service-worker":
"src/service-worker/service-worker.ts",
"update-controller/update-controller":
"src/update-controller/update-controller.ts",
"sync-queue/sync-queue": "src/sync-queue/sync-queue.ts",
"sync-queue/memory-store": "src/sync-queue/memory-store.ts",
"sync-queue/local-storage-store":
"src/sync-queue/local-storage-store.ts",
"sync-queue/indexeddb-store":
"src/sync-queue/indexeddb-store.ts",
"sync-queue/background-sync":
"src/sync-queue/background-sync.ts",
"sync-queue/version-migration":
"src/sync-queue/version-migration.ts",
"push-notifications/push-notifications":
"src/push-notifications/push-notifications.ts",
"push-notifications/vapid":
"src/push-notifications/vapid.ts",
"install-prompt/install-prompt":
"src/install-prompt/install-prompt.ts",
"caching-strategies/caching-strategies":
"src/caching-strategies/caching-strategies.ts",
"caching-strategies/router":
"src/caching-strategies/router.ts",
"manifest/manifest": "src/manifest/manifest.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,7 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "happy-dom",
fileParallelism: false,
testTimeout: 5000,
});