Files
WRNexusJS/packages/pwa/src/advanced.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

182 lines
7.6 KiB
TypeScript

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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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}}))});`;