import { ensure } from "ensured"; import { Client } from "magicbell-js/user-client"; import { createMachine, guard, immediate, invoke, reduce, state, transition } from "robot3"; import { isMobile, isPWA } from "../lib/features"; import { setErrorMessageOnContext } from "../lib/fsm"; import { log } from "../lib/log"; import { isOK } from "../lib/response"; /** * Check if service workers and push notifications are supported in this browser */ export function isSupported() { if (typeof window === "undefined" || typeof navigator === "undefined") return false; return "PushManager" in window && "serviceWorker" in navigator; } // use a template literal, because mitosis strips '' from string literals const DEFAUL_SERVICE_WORKER_PATH = `/sw.js`; /** * Gets the registered service worker, and attempts to create one if no registration exists */ async function registerServiceWorker(options?: { path?: string; } | string) { const scriptUrl = (typeof options === "string" ? options : options?.path) || DEFAUL_SERVICE_WORKER_PATH; // don't register a service-worker if there's already one if (navigator.serviceWorker.controller) return navigator.serviceWorker.ready; await navigator.serviceWorker.register(scriptUrl); return navigator.serviceWorker.ready; } type Props = { client: Client; serviceWorkerPath?: string; }; type Context = { error?: string; registration: ServiceWorkerRegistration | null; tokenId?: string; }; function errorTransition(message?: string) { return transition("error", "error", setErrorMessageOnContext(message)); } function getInitialContext(): Context { return { error: "", tokenId: undefined, registration: null }; } export function createWebPushMachine({ client, serviceWorkerPath }: Props) { return createMachine({ init: state(immediate("requestInstall", guard(() => isMobile() && !isPWA())), immediate("unsupported", guard(() => !isSupported())), immediate("registerServiceWorker")), requestInstall: state(), unsupported: state(), registerServiceWorker: invoke(log.wrap("debug", "webpush.registerServiceWorker", async () => { const registration = await registerServiceWorker({ path: serviceWorkerPath }).catch(() => null); ensure(registration, "Failed to register service worker"); return { registration }; }), transition("done", "checkTokens", reduce((ctx: any, e: any) => ({ ...ctx, registration: e.data.registration }))), errorTransition("Failed to register service worker")), checkTokens: invoke(log.wrap("debug", "webpush.checkTokens", async ctx => { const registration = ctx.registration; ensure(registration, "WebPush service worker registration missing"); const activeSubscription = await registration?.pushManager?.getSubscription(); if (!activeSubscription) return { tokenId: undefined }; const tokens = await client.channels.listWebPushTokens(); const activeTokens = tokens.data?.data?.filter(x => !x.discardedAt); const appToken = activeTokens?.find(x => x.endpoint === activeSubscription.endpoint); return { tokenId: appToken?.id }; }), transition("done", "active", guard((_, e: any) => e.data?.tokenId), reduce((ctx: any, e: any) => ({ ...ctx, tokenId: e.data.tokenId }))), transition("done", "idle"), errorTransition("Failed to fetch WebPush tokens")), idle: state(transition("start", "startInstall")), startInstall: invoke(log.wrap("debug", "webpush.startInstall", async () => { const { data, metadata: res } = await client.integrations.startWebPushInstallation(); ensure(isOK(res), `Failed to start WebPush installation: ${res.status} ${res.statusText}`); ensure(data?.authToken, "Unexpected server response, missing authToken"); return { authToken: data.authToken, publicKey: data.publicKey }; }), transition("done", "saveToken"), errorTransition("Failed to start WebPush installation")), saveToken: invoke(log.wrap("debug", "webpush.saveToken", async (ctx, e) => { const registration = ctx.registration; ensure(registration, "WebPush service worker registration missing"); // strip the base64 padding, it's either that or convert to uint8array const applicationServerKey = e.data.publicKey.replace(/=/g, ""); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }).then(x => x.toJSON()); ensure("endpoint" in subscription, "Failed to subscribe to push notifications, browser did not return an subscription endpoint."); const { data, metadata: res } = await client.channels.saveWebPushToken({ endpoint: subscription.endpoint!, keys: subscription.keys as any }); ensure(isOK(res), `Failed to save WebPush token: ${res.status} ${res.statusText}`); ensure(data?.endpoint, "Unexpected server response, missing endpoint"); }), transition("done", "checkTokens"), errorTransition("Failed to save WebPush token")), removeToken: invoke(log.wrap("debug", "webpush.removeToken", async (ctx, _err) => { const registration = ctx.registration; if (!registration?.pushManager) return; const activeSubscription = await registration.pushManager.getSubscription(); if (activeSubscription) { await activeSubscription.unsubscribe().catch(() => void 0); } if (!ctx.tokenId) return; const { metadata: res } = await client.channels.deleteWebPushToken(ctx.tokenId); ensure(isOK(res), `Failed to discard WebPush token: ${res.status} ${res.statusText}`); }), transition("done", "idle", reduce((ctx: any) => ({ ...ctx, tokenId: undefined }))), errorTransition("Failed to discard WebPush token")), error: state(transition("start", "startInstall")), active: state(transition("discard", "removeToken")) }, getInitialContext); }