138 lines
4.6 KiB
TypeScript
138 lines
4.6 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
|
|
);
|
|
}
|
|
|
|
/** 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.");
|
|
}
|
|
|
|
const reg = await navigator.serviceWorker.register("/sw.js");
|
|
await navigator.serviceWorker.ready;
|
|
|
|
let sub = await reg.pushManager.getSubscription();
|
|
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.");
|
|
}
|
|
|
|
/** 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;
|
|
}
|