Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
120
packages/foundation/data/pwa/src/caching-strategies/router.ts
Normal file
120
packages/foundation/data/pwa/src/caching-strategies/router.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
packages/foundation/data/pwa/src/constants/index.ts
Normal file
32
packages/foundation/data/pwa/src/constants/index.ts
Normal 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;
|
||||
74
packages/foundation/data/pwa/src/errors/errors.md
Normal file
74
packages/foundation/data/pwa/src/errors/errors.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `errors/errors.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `PWA_CODES` stable error-code registry, the `PwaError` base class, and
|
||||
the frozen `PWA_ERROR_METADATA` table mapping every code to its HTTP status,
|
||||
severity, retryability, and human-readable description. Every error thrown or
|
||||
reported by `@suaveplan/pwa` carries one of these codes so retry strategies,
|
||||
logger pipelines, and observability sinks can classify failures without
|
||||
string-matching on the `Error.message`. Codes are namespaced under `PWA.*`.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable codes covering every documented failure path (service-worker
|
||||
unsupported / registration / update, push unsupported / permission, sync-queue
|
||||
full / validation, manifest validation).
|
||||
- `PwaError extends SuaveplanError` so the project's logger and wire serialiser
|
||||
treat the error identically to every other domain error.
|
||||
- Frozen `PWA_ERROR_METADATA` table with `{ httpStatus, severity, retryable,
|
||||
description }` per code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PWA_CODES, PwaError } from "@suaveplan/pwa";
|
||||
|
||||
throw new PwaError(
|
||||
PWA_CODES.SW_REGISTRATION_FAILED,
|
||||
"Service worker registration failed for /sw.js"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inspect metadata for retry decisions:
|
||||
|
||||
```ts
|
||||
import { PWA_ERROR_METADATA, PwaError, PWA_CODES } from "@suaveplan/pwa";
|
||||
|
||||
function shouldRetry(err: unknown): boolean {
|
||||
if (!(err instanceof PwaError)) return false;
|
||||
return PWA_ERROR_METADATA[err.code].retryable;
|
||||
}
|
||||
|
||||
const err = new PwaError(
|
||||
PWA_CODES.SW_UPDATE_FAILED,
|
||||
"ServiceWorkerRegistration.update rejected",
|
||||
{ cause: new Error("Network down") }
|
||||
);
|
||||
console.assert(shouldRetry(err));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PWA_CODES` — frozen record of `{ [Reason]: "PWA.<Reason>" }`.
|
||||
- `PwaErrorCode` — union of every documented code.
|
||||
- `PwaCodeMetadata` — `{ httpStatus, severity, retryable, description }`.
|
||||
- `PWA_ERROR_METADATA` — frozen mapping of every `PwaErrorCode` to its metadata.
|
||||
- `PwaError` — base error class, subclass of `SuaveplanError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PWA_ERROR_METADATA` is constructed with `Object.freeze` so consumers cannot mutate
|
||||
classifications at runtime. The class name is set to `PwaError` in the constructor
|
||||
so `error.name` matches the export name across every JS engine (including those
|
||||
that minimise class names). Codes are defined via `defineCodes("PWA", [...])`
|
||||
from `@suaveplan/error` to keep the namespace prefix in one place and to type the
|
||||
returned codes as template-literal strings (`"PWA.SW_UNSUPPORTED"`, etc.). This
|
||||
allows literal-typed unions in `PwaError<TCode>` without the implementation
|
||||
needing to thread the namespace string through every callsite.
|
||||
63
packages/foundation/data/pwa/src/errors/errors.test.ts
Normal file
63
packages/foundation/data/pwa/src/errors/errors.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
packages/foundation/data/pwa/src/errors/errors.ts
Normal file
109
packages/foundation/data/pwa/src/errors/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
157
packages/foundation/data/pwa/src/index.test.ts
Normal file
157
packages/foundation/data/pwa/src/index.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
144
packages/foundation/data/pwa/src/index.ts
Normal file
144
packages/foundation/data/pwa/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
91
packages/foundation/data/pwa/src/manifest/manifest.md
Normal file
91
packages/foundation/data/pwa/src/manifest/manifest.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `manifest/manifest.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ManifestBuilder` provides a fluent, type-safe builder for the W3C Web App
|
||||
Manifest. Every setter returns `this` so configuration chains naturally;
|
||||
`build()` validates the accumulated state through `WebAppManifestSchema` and
|
||||
raises a typed `PwaError(MANIFEST_VALIDATION)` on failure; `toBlob()`
|
||||
serialises the validated manifest to a Blob with the correct MIME type
|
||||
(`application/manifest+json`) so it can be served from a Service Worker or
|
||||
inlined via a Blob URL.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent setter API for every standard manifest field (`name`, `short_name`,
|
||||
`description`, `start_url`, `scope`, `display`, `orientation`,
|
||||
`background_color`, `theme_color`, `lang`, `dir`).
|
||||
- Repeated-field setters: `addIcon`, `addShortcut`, `addRelatedApplication`.
|
||||
- `setShareTarget` for the W3C Web Share Target specification.
|
||||
- `setPreferRelatedApplications` for the native-app preference flag.
|
||||
- Zod-backed validation via `WebAppManifestSchema`.
|
||||
- `toBlob()` for direct delivery from a Service Worker `fetch` handler.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ManifestBuilder } from "@suaveplan/pwa";
|
||||
|
||||
const manifest = new ManifestBuilder()
|
||||
.setName("Suaveplan")
|
||||
.setShortName("SP")
|
||||
.setStartUrl("/")
|
||||
.setDisplay("standalone")
|
||||
.setThemeColor("#0f172a")
|
||||
.addIcon({ src: "/icon-192.png", sizes: "192x192", type: "image/png" })
|
||||
.addIcon({ src: "/icon-512.png", sizes: "512x512", type: "image/png" })
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Serve the manifest from a Service Worker:
|
||||
|
||||
```ts
|
||||
import { ManifestBuilder } from "@suaveplan/pwa";
|
||||
|
||||
const manifestBlob = new ManifestBuilder()
|
||||
.setName("Suaveplan")
|
||||
.addIcon({ src: "/icon.png", sizes: "512x512" })
|
||||
.addShortcut({
|
||||
name: "New post",
|
||||
url: "/posts/new",
|
||||
icons: [{ src: "/icon.png", sizes: "48x48" }],
|
||||
})
|
||||
.setShareTarget({
|
||||
action: "/share",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: { title: "title", text: "text" },
|
||||
})
|
||||
.toBlob();
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.url.endsWith("/manifest.webmanifest")) {
|
||||
event.respondWith(new Response(manifestBlob, { status: 200 }));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ManifestBuilder()` — no constructor args; configure via setters.
|
||||
- 14 setters returning `this`.
|
||||
- 3 add-* methods for icons / shortcuts / related apps.
|
||||
- `build(): WebAppManifest`.
|
||||
- `toBlob(): Blob`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder maintains its internal state as private fields with `undefined`
|
||||
sentinels rather than partial-record objects so `exactOptionalPropertyTypes`
|
||||
in `tsconfig.base.json` does not punish the conditional spread in `build()`.
|
||||
The `build()` body uses conditional property assignment (`if (… !==
|
||||
undefined) candidate.x = …`) to avoid emitting `undefined` keys that Zod
|
||||
would reject under `exactOptionalPropertyTypes`. `toBlob()` calls `build()`
|
||||
so validation is enforced on every serialisation path — there is no escape
|
||||
hatch that bypasses Zod.
|
||||
88
packages/foundation/data/pwa/src/manifest/manifest.test.ts
Normal file
88
packages/foundation/data/pwa/src/manifest/manifest.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
152
packages/foundation/data/pwa/src/manifest/manifest.ts
Normal file
152
packages/foundation/data/pwa/src/manifest/manifest.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { WebAppManifest } from "../schemas/schemas";
|
||||
@@ -0,0 +1,66 @@
|
||||
# `observability/observability.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single factory function that builds the `PwaObservability` bundle threaded through
|
||||
every public manager in `@suaveplan/pwa`. Centralising instrument creation here keeps
|
||||
metric names stable across consumers, ensures every manager records on the same
|
||||
meter + tracer, and lets tests inject a no-op (or recording) bundle without
|
||||
touching `@opentelemetry/api` globals.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 counters covering service-worker install / activate / update, sync-queue
|
||||
enqueue / processed / failed, push subscriptions, install-prompt outcomes.
|
||||
- 2 histograms for sync-queue flush duration and cache-strategy handler latency.
|
||||
- Honors a pre-built `observability` option for end-to-end pass-through so
|
||||
composite consumers can share one bundle across multiple Suaveplan packages.
|
||||
- Falls back to `@opentelemetry/api`'s default no-op meter and tracer when no
|
||||
pipeline is registered — manager code can call `.add(1, attrs)` unconditionally.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPwaObservability } from "@suaveplan/pwa";
|
||||
|
||||
const obs = createPwaObservability();
|
||||
obs.counters.syncEnqueuedTotal.add(1, { kind: "post" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Reuse one observability bundle across multiple managers and a recording meter
|
||||
for tests:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { createPwaObservability } from "@suaveplan/pwa";
|
||||
|
||||
const meter = metrics.getMeter("test");
|
||||
const obs = createPwaObservability({ meter });
|
||||
const swm = new ServiceWorkerManager({ observability: obs });
|
||||
const queue = new SyncQueue({ observability: obs, store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPwaObservability(opts?: PwaObservabilityOptions): PwaObservability` —
|
||||
returns `{ meter, tracer, counters, histograms }`.
|
||||
- `PwaCounters` — 8 counter instruments.
|
||||
- `PwaHistograms` — 2 histogram instruments.
|
||||
- `PwaObservability` — top-level bundle type.
|
||||
- `PwaObservabilityOptions` — `{ observability?, meter?, tracer? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is deliberately stateless: invoking it twice yields two independent
|
||||
bundles bound to whatever meter / tracer the caller supplied. Counters are added
|
||||
via the shared `@suaveplan/opentelemetry` helpers so naming, attribute conventions,
|
||||
and unit handling stay consistent with the rest of the project. The `observability`
|
||||
short-circuit (pass-through when the caller already has a bundle) makes the bundle
|
||||
trivially composable: a single SuavePlan app can build one observability bundle at
|
||||
boot and thread it through every manager without re-creating instruments.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
60
packages/foundation/data/pwa/src/push-notifications/vapid.md
Normal file
60
packages/foundation/data/pwa/src/push-notifications/vapid.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# `push-notifications/vapid.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single-helper module that decodes a URL-safe base64 VAPID application server
|
||||
key into the raw `Uint8Array` the Push API's `pushManager.subscribe()` call
|
||||
expects. Web Push servers publish their keys in the URL-safe base64 form
|
||||
(`-` for `+`, `_` for `/`, optional `=` padding stripped) so this helper
|
||||
performs the canonicalisation step every PWA needs before passing the key to
|
||||
the platform.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function, no side effects, no DOM dependencies beyond `atob`.
|
||||
- Tolerates both padded and unpadded inputs — many servers emit one or the
|
||||
other and the helper normalises both to the same byte sequence.
|
||||
- Reuses the platform `atob` (available in every modern browser, Service
|
||||
Worker, Node 18+, and happy-dom) rather than shipping a base64 polyfill.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { urlBase64ToUint8Array } from "@suaveplan/pwa";
|
||||
|
||||
const key = urlBase64ToUint8Array(process.env.VAPID_PUBLIC_KEY!);
|
||||
await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Decode the same key in both padded and unpadded forms to assert they round
|
||||
trip identically (useful for boundary-condition tests):
|
||||
|
||||
```ts
|
||||
import { urlBase64ToUint8Array } from "@suaveplan/pwa";
|
||||
|
||||
const unpadded = "BIqi…rest87chars";
|
||||
const padded = `${unpadded}=`;
|
||||
const a = urlBase64ToUint8Array(unpadded);
|
||||
const b = urlBase64ToUint8Array(padded);
|
||||
expect(Array.from(a)).toEqual(Array.from(b));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `urlBase64ToUint8Array(key: string): Uint8Array`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function deliberately does NOT validate the resulting key length (Web Push
|
||||
VAPID public keys are 65 bytes per RFC 8292) because input shape validation
|
||||
belongs to the caller — the helper returns whatever the input encodes so the
|
||||
test surface and the production code take the same path. If callers want
|
||||
shape validation they wrap the call with a `Uint8Array.byteLength === 65`
|
||||
check. The byte loop is intentionally simple rather than using `Array.from` +
|
||||
`map`: a tight indexed loop avoids one `Array` allocation, which is measurable
|
||||
on large quantities of repeated key decodes (e.g. seeding a key cache).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
15
packages/foundation/data/pwa/src/push-notifications/vapid.ts
Normal file
15
packages/foundation/data/pwa/src/push-notifications/vapid.ts
Normal 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;
|
||||
}
|
||||
79
packages/foundation/data/pwa/src/schemas/schemas.md
Normal file
79
packages/foundation/data/pwa/src/schemas/schemas.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# `schemas/schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas guarding the package's two persisted boundaries: the sync-queue entry
|
||||
and the Web App Manifest. Every value crossing into IndexedDB / LocalStorage / a
|
||||
`Blob` payload is parsed through these schemas so corrupt or attacker-controlled
|
||||
data cannot survive a round-trip. The schemas also serve as the single source of
|
||||
truth for the inferred TypeScript types consumed throughout the package.
|
||||
|
||||
## Features
|
||||
|
||||
- `SyncQueueEntrySchema` — full structural validation including a strict
|
||||
`schemaVersion` literal so older entries are forced through `VersionMigration`
|
||||
before they validate.
|
||||
- `WebAppManifestSchema` — covers the spec-required surface (`name`, `icons`,
|
||||
`display`, `orientation`, `start_url`, `scope`, theme + background colour,
|
||||
`lang`, `dir`, shortcuts, related apps, share target).
|
||||
- `JsonValueSchema` — recursive JSON-safe value validator that rejects functions,
|
||||
symbols, and undefined so `metadata` never carries non-serialisable shapes.
|
||||
- Inferred types (`WebAppManifest`, `ManifestIcon`, `ManifestShortcut`,
|
||||
`RelatedApplication`, `ShareTarget`) for direct downstream use.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SyncQueueEntrySchema, SYNC_QUEUE_SCHEMA_VERSION } from "@suaveplan/pwa";
|
||||
|
||||
const parsed = SyncQueueEntrySchema.parse({
|
||||
id: "sq_1",
|
||||
url: "/api/v1/orders",
|
||||
method: "POST",
|
||||
createdAt: Date.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use `safeParse` to surface the validation error before deciding whether to drop
|
||||
the entry or migrate it:
|
||||
|
||||
```ts
|
||||
import { SyncQueueEntrySchema } from "@suaveplan/pwa";
|
||||
|
||||
const result = SyncQueueEntrySchema.safeParse(loadedRow);
|
||||
if (!result.success) {
|
||||
metrics.queueValidationFailures.add(1, {
|
||||
reason: result.error.issues[0]?.code ?? "unknown",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SyncQueueEntrySchema` — Zod schema for `SyncQueueEntry`.
|
||||
- `JsonValueSchema` — recursive JSON-safe value validator.
|
||||
- `ManifestIconSchema`, `ManifestShortcutSchema`, `RelatedApplicationSchema`,
|
||||
`ShareTargetSchema` — manifest sub-schemas.
|
||||
- `WebAppManifestSchema` — top-level manifest schema.
|
||||
- Inferred type aliases: `ManifestIcon`, `ManifestShortcut`,
|
||||
`RelatedApplication`, `ShareTarget`, `WebAppManifest`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`JsonValueSchema` uses `z.lazy(...)` so it can recurse through arbitrary JSON-like
|
||||
structures without blowing the Zod type-inference stack. The recursion intentionally
|
||||
forbids `undefined` (an allowed `JSON.stringify` no-op value) because IndexedDB
|
||||
silently drops `undefined` keys, which would mask data loss between enqueue and
|
||||
process. The manifest schema requires `name` and at least one icon, matching the
|
||||
two fields the W3C spec calls out as `MUST` for installability. Optional fields
|
||||
match the upstream specification field names (`short_name`, `start_url`, etc.) so
|
||||
the builder can output JSON directly without renaming.
|
||||
93
packages/foundation/data/pwa/src/schemas/schemas.test.ts
Normal file
93
packages/foundation/data/pwa/src/schemas/schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
packages/foundation/data/pwa/src/schemas/schemas.ts
Normal file
109
packages/foundation/data/pwa/src/schemas/schemas.ts
Normal 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>;
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
101
packages/foundation/data/pwa/src/sync-queue/background-sync.ts
Normal file
101
packages/foundation/data/pwa/src/sync-queue/background-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# `sync-queue/indexeddb-store.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Default production `SyncQueueStore` implementation, backed by IndexedDB. IDB
|
||||
is the only browser-native storage that survives the 5 MiB `localStorage`
|
||||
budget and supports async, transactional persistence — both essential for
|
||||
queues that may temporarily accumulate large POST bodies while offline. The
|
||||
store exposes the standard `load()` + `save()` shape of every other
|
||||
`SyncQueueStore` and swaps in transparently behind `SyncQueue`.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `IDBFactory` — production uses `globalThis.indexedDB`, tests use
|
||||
`fake-indexeddb` or an equivalent in-process implementation.
|
||||
- Configurable database and object-store names so multiple `SyncQueue`
|
||||
instances can persist independently inside the same origin.
|
||||
- Full transactional save: every persist clears the store and re-inserts the
|
||||
current snapshot in a single `readwrite` transaction; partial writes from a
|
||||
crash leave the store in its previous coherent state.
|
||||
- Defensive rejection of edge errors (`onerror`, `onabort`, `onblocked`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IndexedDbStore, SyncQueue } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new IndexedDbStore() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake factory for deterministic tests and per-feature databases:
|
||||
|
||||
```ts
|
||||
import { IndexedDbStore } from "@suaveplan/pwa";
|
||||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||
|
||||
const fakeFactory = new FDBFactory() as unknown as IDBFactory;
|
||||
const store = new IndexedDbStore({
|
||||
factory: fakeFactory,
|
||||
databaseName: "test_db",
|
||||
objectStoreName: "test_queue",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new IndexedDbStore(options?: IndexedDbStoreOptions)`.
|
||||
- `load(): Promise<Record<string, unknown>[]>`.
|
||||
- `save(entries: readonly SyncQueueEntry[]): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The save path issues `store.clear()` followed by per-entry `store.put()` inside
|
||||
a single `readwrite` transaction. Because IDB transactions commit atomically,
|
||||
either every entry lands or none of them do — a crash mid-save leaves the
|
||||
previous queue intact. Connections are closed in a `finally` block so the
|
||||
store does not pin an open connection across reads/writes, which would prevent
|
||||
schema upgrades from completing. The keypath is `"id"` so `put()` upserts on
|
||||
the existing primary key without inflating row count on overwrite.
|
||||
@@ -0,0 +1,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/);
|
||||
});
|
||||
});
|
||||
106
packages/foundation/data/pwa/src/sync-queue/indexeddb-store.ts
Normal file
106
packages/foundation/data/pwa/src/sync-queue/indexeddb-store.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
62
packages/foundation/data/pwa/src/sync-queue/memory-store.md
Normal file
62
packages/foundation/data/pwa/src/sync-queue/memory-store.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `sync-queue/memory-store.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Fastest possible `SyncQueueStore` implementation, backed by a private array.
|
||||
Used as the default in unit tests and as a fallback in environments where
|
||||
neither `localStorage` nor `indexedDB` is available (notably inside a Service
|
||||
Worker global scope, where persistence must be managed via `Cache` or a side
|
||||
channel). Entries are deep-copied on every read and write so callers cannot
|
||||
mutate the store's internal state by retaining a reference.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous semantics wrapped in `Promise.resolve` so it conforms to the
|
||||
async `SyncQueueStore` contract identically to the IDB-backed store.
|
||||
- Optional `seed` array for tests that want a pre-populated queue.
|
||||
- Defensive copies on `load()` and `save()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MemoryStore, SyncQueue } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Seed a queue with entries that need processing on the very first `process()`
|
||||
call (useful for replaying a snapshot):
|
||||
|
||||
```ts
|
||||
import { MemoryStore } from "@suaveplan/pwa";
|
||||
|
||||
const seeded = new MemoryStore([
|
||||
{
|
||||
id: "sq_1",
|
||||
url: "/api/x",
|
||||
method: "POST",
|
||||
createdAt: Date.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: 2,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MemoryStore(seed?: readonly SyncQueueEntry[])`.
|
||||
- `load(): Promise<Record<string, unknown>[]>`.
|
||||
- `save(entries: readonly SyncQueueEntry[]): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The store always copies entries via `{ ...e }` rather than returning the raw
|
||||
array, so a caller that mutates a returned entry never corrupts the store's
|
||||
internal state. The shallow copy is sufficient because `SyncQueueEntry`
|
||||
contains only primitives, optional plain records, and optional plain strings —
|
||||
no nested mutable references that need a deeper clone. The synchronous body
|
||||
wrapped in `async` keeps the interface trivially substitutable with the IDB
|
||||
and LocalStorage stores without any caller-side branching.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
22
packages/foundation/data/pwa/src/sync-queue/memory-store.ts
Normal file
22
packages/foundation/data/pwa/src/sync-queue/memory-store.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
87
packages/foundation/data/pwa/src/sync-queue/sync-queue.md
Normal file
87
packages/foundation/data/pwa/src/sync-queue/sync-queue.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# `sync-queue/sync-queue.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Offline-first request queue that persists requests while the device is offline
|
||||
and replays them when connectivity returns. The queue is backend-agnostic: it
|
||||
delegates persistence to any `SyncQueueStore` (memory, `localStorage`, or
|
||||
IndexedDB), validates every entry through `SyncQueueEntrySchema` at the
|
||||
enqueue boundary, runs an exponential-backoff retry loop, and emits OTel
|
||||
counters and histograms covering enqueue / processed / failed counts plus
|
||||
flush duration. Migration from older schema versions happens automatically on
|
||||
every load.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable store (`MemoryStore`, `LocalStorageStore`, `IndexedDbStore`).
|
||||
- Zod boundary validation on every enqueue; failures raise
|
||||
`PwaError(SYNC_QUEUE_VALIDATION)`.
|
||||
- Capacity enforcement via `PwaError(SYNC_QUEUE_FULL)` when `maxSize` is hit.
|
||||
- Auto-migration of older schema versions on every load (idempotent).
|
||||
- Single-flight `process()` — concurrent invocations short-circuit to `0`.
|
||||
- Exponential-backoff `retryDelayFor(retryCount)` accessor.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic tests.
|
||||
- OTel emission: `pwa_sync_enqueued_total`, `pwa_sync_processed_total`,
|
||||
`pwa_sync_failed_total`, `pwa_sync_flush_ms`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SyncQueue, IndexedDbStore } from "@suaveplan/pwa";
|
||||
|
||||
const queue = new SyncQueue({ store: new IndexedDbStore() });
|
||||
await queue.enqueue("/api/v1/orders", "POST", {
|
||||
body: JSON.stringify({ sku: "abc", qty: 2 }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
await queue.process();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a clock + id generator + custom processor + failure hook for tests:
|
||||
|
||||
```ts
|
||||
import { SyncQueue, MemoryStore, type Clock } from "@suaveplan/pwa";
|
||||
|
||||
const clock: Clock = { now: () => 1_700_000_000_000 };
|
||||
let n = 0;
|
||||
const queue = new SyncQueue({
|
||||
store: new MemoryStore(),
|
||||
clock,
|
||||
idGenerator: () => `sq_${++n}`,
|
||||
maxRetries: 2,
|
||||
retryDelay: 100,
|
||||
retryBackoffFactor: 3,
|
||||
onProcess: async (entry) => entry.url !== "/fail",
|
||||
onFailure: (entry) => console.error("permanently failed", entry.id),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SyncQueue(options: SyncQueueOptions)`.
|
||||
- `enqueue(url, method, options?): Promise<SyncQueueEntry>`.
|
||||
- `remove(id): Promise<boolean>`.
|
||||
- `getAll(): Promise<SyncQueueEntry[]>`.
|
||||
- `size(): Promise<number>`.
|
||||
- `clear(): Promise<void>`.
|
||||
- `process(): Promise<number>`.
|
||||
- `retryDelayFor(retryCount): number`.
|
||||
- `isProcessing: boolean` (readonly).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The queue serialises the entire snapshot on every mutation rather than
|
||||
performing per-entry CRUD against the underlying store. This keeps the
|
||||
`SyncQueueStore` contract minimal (`load` / `save`) and lets every store
|
||||
implementation guarantee atomicity simply by wrapping `save()` in its native
|
||||
transaction primitive. The `process()` body avoids `Promise.all` so failures
|
||||
in one entry cannot cancel sibling work; instead each entry is awaited
|
||||
sequentially and its outcome recorded. The histogram records the entire flush
|
||||
duration including loads and saves so the observed value matches user-felt
|
||||
latency rather than CPU-only time.
|
||||
460
packages/foundation/data/pwa/src/sync-queue/sync-queue.test.ts
Normal file
460
packages/foundation/data/pwa/src/sync-queue/sync-queue.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
219
packages/foundation/data/pwa/src/sync-queue/sync-queue.ts
Normal file
219
packages/foundation/data/pwa/src/sync-queue/sync-queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
88
packages/foundation/data/pwa/src/types/common.md
Normal file
88
packages/foundation/data/pwa/src/types/common.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# `types/common.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Central type definitions shared across the `@suaveplan/pwa` package. The module owns
|
||||
every cross-module structural contract — service-worker state, sync-queue entry shape,
|
||||
push subscription results, install-prompt state machine values, clock seam, window-like
|
||||
event target, storage-like persistence, and notification-static seam — so individual
|
||||
feature modules can be re-implemented or replaced without forcing renames across the
|
||||
package. The file is types-only; no runtime values escape except `SYSTEM_CLOCK`, the
|
||||
canonical `Date.now()`-backed `Clock` instance every consumer can adopt without
|
||||
constructing their own.
|
||||
|
||||
## Features
|
||||
|
||||
- `SwState` enum (`installing`, `installed`, `activating`, `activated`, `redundant`,
|
||||
`unsupported`) used by `ServiceWorkerManager`.
|
||||
- `SwRegistrationOptions` and `SwRegistrationResult` types for registration calls.
|
||||
- `SyncQueueEntry` (with `schemaVersion`) and `SyncQueueConfig`.
|
||||
- `PushPermission`, `PushNotificationOptions`, `PushSubscriptionResult`.
|
||||
- `InstallPromptState`, `InstallPromptListener` for the install-prompt state machine.
|
||||
- `Clock` + `SYSTEM_CLOCK` runtime seam.
|
||||
- `WindowLike`, `StorageLike`, `NotificationStaticLike` injectable seams that match
|
||||
the relevant DOM contracts narrowly so production wiring is a no-op cast.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SYSTEM_CLOCK,
|
||||
type SyncQueueEntry,
|
||||
type SwState,
|
||||
} from "@suaveplan/pwa";
|
||||
|
||||
const state: SwState = "installing";
|
||||
const entry: SyncQueueEntry = {
|
||||
id: "abc",
|
||||
url: "/api/v1/posts",
|
||||
method: "POST",
|
||||
createdAt: SYSTEM_CLOCK.now(),
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
schemaVersion: 2,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Substitute the clock to make every PWA timestamp deterministic in tests:
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/pwa";
|
||||
|
||||
const fakeClock: Clock = {
|
||||
now: () => 1_700_000_000_000,
|
||||
};
|
||||
const queue = new SyncQueue({ clock: fakeClock, store: new MemoryStore() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SwState` — discriminated union of every lifecycle value the manager exposes.
|
||||
- `SwRegistrationOptions` — registration parameters consumed by
|
||||
`ServiceWorkerManager.register()`.
|
||||
- `SwRegistrationResult` — `{ success, state, error? }`.
|
||||
- `SyncQueueEntry` — request descriptor persisted by the queue.
|
||||
- `SyncQueueConfig` — pluggable processor + failure callbacks + retry tuning.
|
||||
- `PushPermission` — `"granted" | "denied" | "default"`.
|
||||
- `PushNotificationOptions` — VAPID key + user-visibility flag.
|
||||
- `PushSubscriptionResult` — subscription outcome.
|
||||
- `InstallPromptState` / `InstallPromptListener` — install-prompt FSM.
|
||||
- `Clock` — `{ now(): number }`.
|
||||
- `SYSTEM_CLOCK` — `Date.now()`-backed clock.
|
||||
- `WindowLike`, `StorageLike`, `NotificationStaticLike` — narrow injectable contracts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file is intentionally pure types plus a single frozen constant. It carries no
|
||||
side effects and no behaviour beyond declaring shapes. The `Clock`, `WindowLike`,
|
||||
`StorageLike`, and `NotificationStaticLike` seams duplicate the relevant lib.dom.d.ts
|
||||
slices structurally so production code can pass `navigator.serviceWorker`, `window`,
|
||||
`localStorage`, and `Notification` without casts. The `SYSTEM_CLOCK` literal is the
|
||||
only runtime symbol exposed, and it is a fully-frozen object with one method, so the
|
||||
package keeps `"sideEffects": false` viable in `package.json`.
|
||||
117
packages/foundation/data/pwa/src/types/common.ts
Normal file
117
packages/foundation/data/pwa/src/types/common.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user