release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# @wrnexus/pwa
|
||||
|
||||
Official PWA primitives for manifests, service workers, offline pages and precaching, runtime
|
||||
caching, background synchronization, push notifications, install/update events, offline mutation
|
||||
stores, and conflict resolution. `createOfflineQueue()` accepts a durable IndexedDB-style store and
|
||||
retries requests with stable idempotency headers.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@wrnexus/pwa",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { OfflineMutation, OfflineQueueStore } from "./index.ts";
|
||||
|
||||
export interface IndexedDbMigration {
|
||||
version: number;
|
||||
migrate(db: IDBDatabase, transaction: IDBTransaction): void;
|
||||
}
|
||||
export function openPwaDatabase(
|
||||
name: string,
|
||||
migrations: IndexedDbMigration[],
|
||||
factory: IDBFactory = indexedDB,
|
||||
): Promise<IDBDatabase> {
|
||||
if (!name.trim()) throw new Error("IndexedDB name is required");
|
||||
const ordered = [...migrations].sort((a, b) => a.version - b.version);
|
||||
if (ordered.some((migration, index) => migration.version !== index + 1))
|
||||
throw new Error("WRN-PWA-IDB-MIGRATIONS: versions must be contiguous from 1");
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = factory.open(name, ordered.at(-1)?.version ?? 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => reject(new Error("WRN-PWA-IDB-BLOCKED"));
|
||||
request.onupgradeneeded = (event) => {
|
||||
const transaction = request.transaction!;
|
||||
for (const migration of ordered)
|
||||
if (migration.version > event.oldVersion && migration.version <= event.newVersion!)
|
||||
migration.migrate(request.result, transaction);
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
export function indexedDbOfflineQueueStore(
|
||||
db: IDBDatabase,
|
||||
storeName = "mutations",
|
||||
): OfflineQueueStore {
|
||||
return {
|
||||
async list() {
|
||||
return (
|
||||
(await idbRequest(
|
||||
db.transaction(storeName, "readonly").objectStore(storeName).getAll(),
|
||||
)) as OfflineMutation[]
|
||||
).sort((a, b) => a.createdAt - b.createdAt);
|
||||
},
|
||||
async put(item) {
|
||||
await idbRequest(
|
||||
db.transaction(storeName, "readwrite").objectStore(storeName).put(structuredClone(item)),
|
||||
);
|
||||
},
|
||||
async remove(id) {
|
||||
await idbRequest(db.transaction(storeName, "readwrite").objectStore(storeName).delete(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
export const offlineQueueMigration: IndexedDbMigration = {
|
||||
version: 1,
|
||||
migrate(db) {
|
||||
if (!db.objectStoreNames.contains("mutations"))
|
||||
db.createObjectStore("mutations", { keyPath: "id" });
|
||||
},
|
||||
};
|
||||
|
||||
export interface StoredPushSubscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: { p256dh: string; auth: string };
|
||||
createdAt: number;
|
||||
}
|
||||
export interface PushSubscriptionStore {
|
||||
put(value: StoredPushSubscription): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
list(userId: string): Promise<StoredPushSubscription[]>;
|
||||
}
|
||||
export function memoryPushSubscriptionStore(): PushSubscriptionStore {
|
||||
const values = new Map<string, StoredPushSubscription>();
|
||||
return {
|
||||
async put(value) {
|
||||
values.set(value.id, structuredClone(value));
|
||||
},
|
||||
async remove(id) {
|
||||
values.delete(id);
|
||||
},
|
||||
async list(userId) {
|
||||
return [...values.values()]
|
||||
.filter((value) => value.userId === userId)
|
||||
.map((value) => structuredClone(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
export interface PushSqlClient {
|
||||
query<T = Record<string, unknown>>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>;
|
||||
}
|
||||
export function postgresPushSubscriptionStore(db: PushSqlClient): PushSubscriptionStore {
|
||||
return {
|
||||
async put(value) {
|
||||
await db.query(
|
||||
`INSERT INTO wrnexus_push_subscriptions (id,user_id,endpoint,expiration_time,p256dh,auth,created_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET user_id=$2,endpoint=$3,expiration_time=$4,p256dh=$5,auth=$6`,
|
||||
[
|
||||
value.id,
|
||||
value.userId,
|
||||
value.endpoint,
|
||||
value.expirationTime ?? null,
|
||||
value.keys.p256dh,
|
||||
value.keys.auth,
|
||||
value.createdAt,
|
||||
],
|
||||
);
|
||||
},
|
||||
async remove(id) {
|
||||
await db.query(`DELETE FROM wrnexus_push_subscriptions WHERE id=$1`, [id]);
|
||||
},
|
||||
async list(userId) {
|
||||
const result = await db.query<any>(
|
||||
`SELECT id,user_id AS "userId",endpoint,expiration_time AS "expirationTime",json_build_object('p256dh',p256dh,'auth',auth) AS keys,created_at AS "createdAt" FROM wrnexus_push_subscriptions WHERE user_id=$1 ORDER BY created_at`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
};
|
||||
}
|
||||
export const POSTGRES_PUSH_SUBSCRIPTION_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_push_subscriptions (id text PRIMARY KEY,user_id text NOT NULL,endpoint text NOT NULL,expiration_time bigint,p256dh text NOT NULL,auth text NOT NULL,created_at bigint NOT NULL); CREATE INDEX IF NOT EXISTS wrnexus_push_user ON wrnexus_push_subscriptions (user_id);`;
|
||||
export function createPushSubscriptionService(
|
||||
store: PushSubscriptionStore,
|
||||
options: { now?: () => number; maxPerUser?: number } = {},
|
||||
) {
|
||||
const now = options.now ?? Date.now;
|
||||
const max = options.maxPerUser ?? 20;
|
||||
return {
|
||||
async subscribe(
|
||||
userId: string,
|
||||
value: {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: { p256dh: string; auth: string };
|
||||
},
|
||||
) {
|
||||
if (!userId.trim()) throw new Error("Push subscription user is required");
|
||||
const endpoint = new URL(value.endpoint);
|
||||
if (endpoint.protocol !== "https:") throw new Error("Push endpoint must use HTTPS");
|
||||
if (
|
||||
!value.keys?.p256dh ||
|
||||
!value.keys.auth ||
|
||||
value.keys.p256dh.length > 1024 ||
|
||||
value.keys.auth.length > 1024
|
||||
)
|
||||
throw new Error("Invalid push subscription keys");
|
||||
const existing = await store.list(userId);
|
||||
const id = await crypto.subtle
|
||||
.digest("SHA-256", new TextEncoder().encode(value.endpoint))
|
||||
.then((bytes) =>
|
||||
[...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""),
|
||||
);
|
||||
if (!existing.some((entry) => entry.id === id) && existing.length >= max)
|
||||
throw new Error("WRN-PWA-PUSH-CAPACITY");
|
||||
const record = { id, userId, ...value, keys: { ...value.keys }, createdAt: now() };
|
||||
await store.put(record);
|
||||
return record;
|
||||
},
|
||||
unsubscribe: (id: string) => store.remove(id),
|
||||
list: (userId: string) => store.list(userId),
|
||||
};
|
||||
}
|
||||
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
||||
);
|
||||
export function renderOfflineQueueReview(
|
||||
items: OfflineMutation[],
|
||||
conflicts: Array<{ id: string; message: string }> = [],
|
||||
): string {
|
||||
return `<section data-wrn-pwa-review><h2>Offline changes</h2><ul>${items.map((item) => `<li data-mutation-id="${esc(item.id)}"><strong>${esc(item.method)}</strong> ${esc(item.endpoint)} · ${item.attempts} attempts <button type="button" data-pwa-retry="${esc(item.id)}">Retry</button><button type="button" data-pwa-remove="${esc(item.id)}">Remove</button></li>`).join("") || "<li>No pending changes</li>"}</ul><h3>Conflicts</h3><ul>${conflicts.map((item) => `<li data-conflict-id="${esc(item.id)}">${esc(item.message)} <button type="button" data-pwa-client="${esc(item.id)}">Keep local</button><button type="button" data-pwa-server="${esc(item.id)}">Use server</button></li>`).join("") || "<li>No conflicts</li>"}</ul></section>`;
|
||||
}
|
||||
export const PWA_REVIEW_RUNTIME = `document.addEventListener("click",event=>{const button=event.target.closest("[data-pwa-retry],[data-pwa-remove],[data-pwa-client],[data-pwa-server]");if(!button)return;const action=button.hasAttribute("data-pwa-retry")?"retry":button.hasAttribute("data-pwa-remove")?"remove":button.hasAttribute("data-pwa-client")?"client":"server";const id=button.getAttribute("data-pwa-"+action);dispatchEvent(new CustomEvent("wrnexus:pwa-review",{detail:{action,id}}))});`;
|
||||
@@ -0,0 +1,190 @@
|
||||
export type RuntimeCacheStrategy = "network-first" | "cache-first" | "stale-while-revalidate";
|
||||
export interface RuntimeCacheRule {
|
||||
pattern: string;
|
||||
strategy: RuntimeCacheStrategy;
|
||||
cacheName?: string;
|
||||
methods?: string[];
|
||||
}
|
||||
export interface ServiceWorkerOptions {
|
||||
cacheName?: string;
|
||||
offlineUrl?: string;
|
||||
startUrl?: string;
|
||||
cacheUrls?: string[];
|
||||
runtimeCaching?: RuntimeCacheRule[];
|
||||
backgroundSyncTag?: string;
|
||||
}
|
||||
export interface WebManifestOptions {
|
||||
name: string;
|
||||
shortName?: string;
|
||||
description?: string;
|
||||
id?: string;
|
||||
startUrl?: string;
|
||||
scope?: string;
|
||||
display?: "standalone" | "fullscreen" | "minimal-ui" | "browser";
|
||||
themeColor?: string;
|
||||
backgroundColor?: string;
|
||||
icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
|
||||
shortcuts?: unknown[];
|
||||
screenshots?: unknown[];
|
||||
categories?: string[];
|
||||
lang?: string;
|
||||
}
|
||||
export function createWebManifest(options: WebManifestOptions) {
|
||||
return {
|
||||
id: options.id ?? options.startUrl ?? "/",
|
||||
name: options.name,
|
||||
short_name: options.shortName ?? options.name,
|
||||
description: options.description,
|
||||
start_url: options.startUrl ?? "/",
|
||||
scope: options.scope ?? "/",
|
||||
display: options.display ?? "standalone",
|
||||
theme_color: options.themeColor ?? "#0f172a",
|
||||
background_color: options.backgroundColor ?? "#0f172a",
|
||||
icons: options.icons ?? [],
|
||||
shortcuts: options.shortcuts ?? [],
|
||||
screenshots: options.screenshots ?? [],
|
||||
categories: options.categories ?? [],
|
||||
lang: options.lang ?? "en",
|
||||
};
|
||||
}
|
||||
export function generateServiceWorker(options: ServiceWorkerOptions = {}): string {
|
||||
const offline = options.offlineUrl ?? options.startUrl ?? "/";
|
||||
const urls = [...new Set([offline, ...(options.cacheUrls ?? [])])];
|
||||
const rules = options.runtimeCaching ?? [
|
||||
{ pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] },
|
||||
];
|
||||
return `const CACHE=${JSON.stringify(options.cacheName ?? "wrnexus-pwa-v1")};const OFFLINE=${JSON.stringify(offline)};const PRECACHE=${JSON.stringify(urls)};const RULES=${JSON.stringify(rules)};self.addEventListener("install",event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(PRECACHE)));self.skipWaiting()});self.addEventListener("activate",event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith("wrnexus-pwa-")&&key!==CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim())));const networkFirst=async request=>{try{const response=await fetch(request);if(response.ok){const cache=await caches.open(CACHE);await cache.put(request,response.clone())}return response}catch{return(await caches.match(request))||(request.mode==="navigate"?await caches.match(OFFLINE):Response.error())}};const cacheFirst=async request=>(await caches.match(request))||networkFirst(request);const stale=async request=>{const hit=await caches.match(request);const update=networkFirst(request);return hit||(await update)};self.addEventListener("fetch",event=>{const rule=RULES.find(item=>(item.methods||["GET"]).includes(event.request.method)&&new RegExp(item.pattern).test(event.request.url));if(!rule)return;event.respondWith(rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request):networkFirst(event.request))});self.addEventListener("sync",event=>{if(event.tag===${JSON.stringify(options.backgroundSyncTag ?? "wrnexus-offline-sync")})event.waitUntil(self.clients.matchAll().then(clients=>clients.forEach(client=>client.postMessage({type:"wrnexus:background-sync"}))))});self.addEventListener("push",event=>{const data=event.data?.json?.()||{};event.waitUntil(self.registration.showNotification(data.title||"Notification",{body:data.body,icon:data.icon,data:{url:data.url||"/"}}))});self.addEventListener("notificationclick",event=>{event.notification.close();event.waitUntil(clients.openWindow(event.notification.data?.url||"/"))});`;
|
||||
}
|
||||
export interface OfflineMutation<T = unknown> {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
payload: T;
|
||||
attempts: number;
|
||||
}
|
||||
export interface OfflineQueueStore {
|
||||
list(): Promise<OfflineMutation[]>;
|
||||
put(item: OfflineMutation): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
export function memoryOfflineQueueStore(): OfflineQueueStore {
|
||||
const values = new Map<string, OfflineMutation>();
|
||||
return {
|
||||
async list() {
|
||||
return [...values.values()]
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.map((value) => structuredClone(value));
|
||||
},
|
||||
async put(value) {
|
||||
values.set(value.id, structuredClone(value));
|
||||
},
|
||||
async remove(id) {
|
||||
values.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
export type ConflictResolution<T> = { action: "client" | "server" | "merge"; value: T };
|
||||
export function resolveOfflineConflict<T extends object>(
|
||||
client: T,
|
||||
server: T,
|
||||
strategy:
|
||||
| "client-wins"
|
||||
| "server-wins"
|
||||
| "last-write-wins"
|
||||
| ((client: T, server: T) => T) = "last-write-wins",
|
||||
): ConflictResolution<T> {
|
||||
if (typeof strategy === "function") return { action: "merge", value: strategy(client, server) };
|
||||
if (strategy === "client-wins") return { action: "client", value: client };
|
||||
if (strategy === "server-wins") return { action: "server", value: server };
|
||||
const clientUpdatedAt = Number((client as { updatedAt?: number }).updatedAt ?? 0);
|
||||
const serverUpdatedAt = Number((server as { updatedAt?: number }).updatedAt ?? 0);
|
||||
return clientUpdatedAt >= serverUpdatedAt
|
||||
? { action: "client", value: client }
|
||||
: { action: "server", value: server };
|
||||
}
|
||||
export function createOfflineQueue(
|
||||
options: {
|
||||
store?: OfflineQueueStore;
|
||||
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
maxItems?: number;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
) {
|
||||
const store = options.store ?? memoryOfflineQueueStore();
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const now = options.now ?? Date.now;
|
||||
const maxItems = options.maxItems ?? 1000;
|
||||
return {
|
||||
async enqueue<T>(
|
||||
input: Omit<OfflineMutation<T>, "id" | "createdAt" | "updatedAt" | "attempts">,
|
||||
) {
|
||||
if ((await store.list()).length >= maxItems) throw new Error("WRN-PWA-OFFLINE-CAPACITY");
|
||||
const timestamp = now();
|
||||
const item: OfflineMutation<T> = {
|
||||
...input,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
attempts: 0,
|
||||
};
|
||||
await store.put(item);
|
||||
return item;
|
||||
},
|
||||
list: () => store.list(),
|
||||
async sync() {
|
||||
const results: Array<{ id: string; ok: boolean; status?: number }> = [];
|
||||
for (const item of await store.list()) {
|
||||
item.attempts += 1;
|
||||
item.updatedAt = now();
|
||||
try {
|
||||
const response = await request(item.endpoint, {
|
||||
method: item.method,
|
||||
headers: { "content-type": "application/json", "x-wrnexus-offline-id": item.id },
|
||||
body: JSON.stringify(item.payload),
|
||||
});
|
||||
if (response.ok) await store.remove(item.id);
|
||||
else await store.put(item);
|
||||
results.push({ id: item.id, ok: response.ok, status: response.status });
|
||||
} catch {
|
||||
await store.put(item);
|
||||
results.push({ id: item.id, ok: false });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
remove: (id: string) => store.remove(id),
|
||||
};
|
||||
}
|
||||
export function pwaClientRuntime(serviceWorkerUrl = "/sw.js"): string {
|
||||
return `if("serviceWorker"in navigator){addEventListener("load",async()=>{const registration=await navigator.serviceWorker.register(${JSON.stringify(serviceWorkerUrl)});if(registration.waiting)dispatchEvent(new CustomEvent("wrnexus:pwa-update",{detail:{registration}}));registration.addEventListener("updatefound",()=>dispatchEvent(new CustomEvent("wrnexus:pwa-update-found",{detail:{registration}})))})}let wrnexusInstallPrompt;addEventListener("beforeinstallprompt",event=>{event.preventDefault();wrnexusInstallPrompt=event;dispatchEvent(new CustomEvent("wrnexus:pwa-installable"))});window.WrNexusPwa={install:async()=>{if(!wrnexusInstallPrompt)return false;await wrnexusInstallPrompt.prompt();const result=await wrnexusInstallPrompt.userChoice;wrnexusInstallPrompt=null;return result.outcome==="accepted"}};`;
|
||||
}
|
||||
export async function subscribeToPush(
|
||||
registration: ServiceWorkerRegistration,
|
||||
publicKey: Uint8Array,
|
||||
): Promise<PushSubscription> {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") throw new Error("WRN-PWA-PUSH-DENIED");
|
||||
return registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: Uint8Array.from(publicKey).buffer,
|
||||
});
|
||||
}
|
||||
export {
|
||||
openPwaDatabase,
|
||||
indexedDbOfflineQueueStore,
|
||||
offlineQueueMigration,
|
||||
memoryPushSubscriptionStore,
|
||||
postgresPushSubscriptionStore,
|
||||
POSTGRES_PUSH_SUBSCRIPTION_SCHEMA,
|
||||
createPushSubscriptionService,
|
||||
renderOfflineQueueReview,
|
||||
PWA_REVIEW_RUNTIME,
|
||||
} from "./advanced.ts";
|
||||
export type {
|
||||
IndexedDbMigration,
|
||||
StoredPushSubscription,
|
||||
PushSubscriptionStore,
|
||||
PushSqlClient,
|
||||
} from "./advanced.ts";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createPushSubscriptionService,
|
||||
memoryPushSubscriptionStore,
|
||||
postgresPushSubscriptionStore,
|
||||
renderOfflineQueueReview,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("push subscriptions validate, deduplicate and persist per user", async () => {
|
||||
const service = createPushSubscriptionService(memoryPushSubscriptionStore(), {
|
||||
maxPerUser: 1,
|
||||
now: () => 10,
|
||||
});
|
||||
const value = { endpoint: "https://push.test/sub", keys: { p256dh: "public", auth: "secret" } };
|
||||
const first = await service.subscribe("user", value);
|
||||
expect((await service.list("user"))[0]).toEqual(first);
|
||||
expect((await service.subscribe("user", value)).id).toBe(first.id);
|
||||
await expect(
|
||||
service.subscribe("user", { ...value, endpoint: "https://push.test/other" }),
|
||||
).rejects.toThrow("CAPACITY");
|
||||
await expect(
|
||||
service.subscribe("user", { ...value, endpoint: "http://push.test/insecure" }),
|
||||
).rejects.toThrow("HTTPS");
|
||||
});
|
||||
|
||||
test("PostgreSQL subscriptions parameterize endpoint and user", async () => {
|
||||
const calls: unknown[][] = [];
|
||||
const store = postgresPushSubscriptionStore({
|
||||
async query<T>(_sql: string, params?: unknown[]) {
|
||||
calls.push(params ?? []);
|
||||
return { rows: [] as T[] };
|
||||
},
|
||||
});
|
||||
await store.put({
|
||||
id: "id",
|
||||
userId: "user",
|
||||
endpoint: "https://push.test",
|
||||
keys: { p256dh: "p", auth: "a" },
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(calls[0]?.slice(0, 3)).toEqual(["id", "user", "https://push.test"]);
|
||||
});
|
||||
|
||||
test("offline review UI escapes payload-derived identifiers and conflicts", () => {
|
||||
const html = renderOfflineQueueReview(
|
||||
[
|
||||
{
|
||||
id: `"><script>`,
|
||||
endpoint: "/api/save",
|
||||
method: "POST",
|
||||
payload: {},
|
||||
attempts: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[{ id: "c", message: "<img onerror=alert(1)>" }],
|
||||
);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("data-pwa-retry");
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createOfflineQueue,
|
||||
createWebManifest,
|
||||
generateServiceWorker,
|
||||
memoryOfflineQueueStore,
|
||||
pwaClientRuntime,
|
||||
resolveOfflineConflict,
|
||||
} from "../src/index.ts";
|
||||
describe("PWA platform", () => {
|
||||
test("generates manifest, caching, sync and push", () => {
|
||||
expect(createWebManifest({ name: "Field App" })).toMatchObject({
|
||||
name: "Field App",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
});
|
||||
const source = generateServiceWorker({ offlineUrl: "/offline", cacheUrls: ["/app.css"] });
|
||||
expect(source).toContain("wrnexus:background-sync");
|
||||
expect(source).toContain("notificationclick");
|
||||
expect(pwaClientRuntime()).toContain("wrnexus:pwa-installable");
|
||||
});
|
||||
test("queues, retries and removes successful mutations", async () => {
|
||||
let available = false;
|
||||
const store = memoryOfflineQueueStore();
|
||||
const queue = createOfflineQueue({
|
||||
store,
|
||||
now: () => 10,
|
||||
fetch: async () => (available ? new Response("ok") : Promise.reject(new Error("offline"))),
|
||||
});
|
||||
await queue.enqueue({
|
||||
endpoint: "https://example.test/forms",
|
||||
method: "POST",
|
||||
payload: { name: "A" },
|
||||
});
|
||||
expect((await queue.sync())[0]?.ok).toBe(false);
|
||||
expect((await queue.list())[0]?.attempts).toBe(1);
|
||||
available = true;
|
||||
expect((await queue.sync())[0]?.ok).toBe(true);
|
||||
expect(await queue.list()).toEqual([]);
|
||||
});
|
||||
test("resolves conflicts", () => {
|
||||
expect(
|
||||
resolveOfflineConflict({ updatedAt: 2, value: "client" }, { updatedAt: 1, value: "server" })
|
||||
.action,
|
||||
).toBe("client");
|
||||
expect(
|
||||
resolveOfflineConflict({ value: 1 }, { value: 2 }, (a, b) => ({ value: a.value + b.value }))
|
||||
.value,
|
||||
).toEqual({ value: 3 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user