Files
spsays-training/lib/push.ts
T
2026-07-02 21:32:22 +05:30

198 lines
6.9 KiB
TypeScript

/**
* Web Push client helpers.
*
* Flow: read the VAPID public key from the BFF → register the service worker
* → request notification permission → subscribe via PushManager → send the
* subscription to the BFF (which stores it and pushes to it on each
* notification). Disabling unsubscribes locally and on the server.
*
* All BFF calls ride the auth cookie (credentials: "include"), matching the
* other lib clients.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** True when the browser can do Web Push at all. */
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** iPhone/iPad/iPod (incl. iPadOS, which reports as "Macintosh" + touch). */
export function isIOS(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
return (
/iPad|iPhone|iPod/.test(ua) ||
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua))
);
}
/** Running as an installed (Home Screen) app, not a browser tab. */
export function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
// iOS Safari's non-standard flag…
(navigator as Navigator & { standalone?: boolean }).standalone === true ||
// …and the standard display-mode for everyone else.
window.matchMedia("(display-mode: standalone)").matches
);
}
/**
* iOS only allows Web Push when the site has been added to the Home Screen
* (iOS 16.4+). In a plain Safari tab the push APIs don't exist, so this is
* the case where we should guide the user to "Add to Home Screen" rather
* than show a dead "unsupported".
*/
export function needsIOSInstall(): boolean {
return isIOS() && !isStandalone() && !pushSupported();
}
/** The browser's current notification permission, or "unsupported". */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
type VapidKey = { public_key: string; enabled: boolean };
async function getVapidKey(): Promise<VapidKey> {
const res = await fetch(`${BFF_BASE}/api/v1/push/key`, {
credentials: "include",
});
if (!res.ok) throw new Error("Could not load push configuration");
const body = (await res.json()) as { data?: VapidKey };
return body.data ?? { public_key: "", enabled: false };
}
async function registration(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) return null;
return (await navigator.serviceWorker.getRegistration()) ?? null;
}
/** Whether push is currently enabled (permission granted + a live sub). */
export async function isPushEnabled(): Promise<boolean> {
if (!pushSupported() || Notification.permission !== "granted") return false;
const reg = await registration();
if (!reg) return false;
return !!(await reg.pushManager.getSubscription());
}
/** Whether the server has push configured (VAPID keys present). */
export async function pushConfigured(): Promise<boolean> {
try {
return (await getVapidKey()).enabled;
} catch {
return false;
}
}
/**
* Enable push: requests permission, subscribes, and registers the
* subscription with the BFF. Throws a user-facing message on failure.
*/
export async function enablePush(): Promise<void> {
if (!pushSupported()) {
throw new Error("This browser doesn't support push notifications.");
}
const { public_key, enabled } = await getVapidKey();
if (!enabled || !public_key) {
throw new Error("Push notifications aren't available right now.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
// The service worker is registered app-wide by ServiceWorkerRegistrar (with
// the same `?bff=` query, so there's a single registration/scope). Reuse it;
// only register here as a fallback if it somehow isn't present yet.
const reg =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register(
`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`,
));
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
// A subscription minted under a previous VAPID key still looks alive
// locally, but every send fails with a credential mismatch — drop it and
// subscribe fresh under the current key.
if (sub && !matchesServerKey(sub, public_key)) {
await sub.unsubscribe().catch(() => {
/* stale anyway — subscribe() below replaces it */
});
sub = null;
}
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
}
const json = sub.toJSON() as {
endpoint?: string;
keys?: { p256dh?: string; auth?: string };
};
const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
});
if (!res.ok) throw new Error("Could not save your subscription.");
}
/**
* Whether an existing subscription was created with the server's current
* VAPID public key. Browsers that don't expose applicationServerKey (older
* Safari) return null — treat that as a match rather than churn the sub.
*/
function matchesServerKey(sub: PushSubscription, publicKey: string): boolean {
const current = sub.options?.applicationServerKey;
if (!current) return true;
const a = new Uint8Array(current);
const b = urlBase64ToUint8Array(publicKey);
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/** Disable push: unsubscribe locally and remove the subscription server-side. */
export async function disablePush(): Promise<void> {
const reg = await registration();
const sub = reg ? await reg.pushManager.getSubscription() : null;
if (!sub) return;
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {
/* best-effort */
});
}
/** VAPID public keys are base64url; PushManager wants a Uint8Array. */
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
// Back with a concrete ArrayBuffer so the type is Uint8Array<ArrayBuffer>,
// which PushManager.subscribe's applicationServerKey requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}