From 3c98ec1b1b4cbb2ed46ae8d05a310d653a6de4af Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 06:14:40 -0700 Subject: [PATCH] feat: service worker push notification router with topic registry (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a topic-based push notification router so UI modules can subscribe to specific event topics, with 60s coalescence and a foreground in-app toast fallback. - types/notification.ts: topic/payload/action/toast types and bounds (3-level taxonomy, 200 topics × 5 handlers, 60s window, 2 actions, 4KB payload) - utils/topicRouter.ts: topic trie — insert/match with trailing-wildcard ancestor matching (meter.* → meter.water.breach), limit enforcement, isolated emit, plus the shared coalescenceKey helper - services/pushSubscriptionManager.ts: VAPID subscribe/unsubscribe with userVisibleOnly, REST register/DELETE, urlBase64ToUint8Array (injectable) - store/slices/notificationSlice.ts: coalescing queue store + useNotifications; identical events increment a count and extend the 60s auto-dismiss timer - hooks/usePushNotifications.ts: permission prompt, subscribe, and a navigator.serviceWorker message listener fanning foreground pushes to the topic router and toast queue - components/panels/ToastContainer.tsx: coalesced toasts with '(N more)' and up to 2 action buttons (url/route) - public/sw.js: push + notificationclick listeners — foreground clients get a postMessage, background shows a tag-coalesced system notification - unit tests for the router, subscription manager, and coalescence store --- public/sw.js | 113 +++++++++++++ src/components/panels/ToastContainer.tsx | 121 ++++++++++++++ src/hooks/usePushNotifications.ts | 162 +++++++++++++++++++ src/services/pushSubscriptionManager.ts | 136 ++++++++++++++++ src/store/slices/notificationSlice.ts | 151 +++++++++++++++++ src/types/notification.ts | 83 ++++++++++ src/utils/topicRouter.ts | 180 +++++++++++++++++++++ tests/unit/notificationSlice.test.ts | 139 ++++++++++++++++ tests/unit/pushSubscriptionManager.test.ts | 158 ++++++++++++++++++ tests/unit/topicRouter.test.ts | 170 +++++++++++++++++++ 10 files changed, 1413 insertions(+) create mode 100644 src/components/panels/ToastContainer.tsx create mode 100644 src/hooks/usePushNotifications.ts create mode 100644 src/services/pushSubscriptionManager.ts create mode 100644 src/store/slices/notificationSlice.ts create mode 100644 src/types/notification.ts create mode 100644 src/utils/topicRouter.ts create mode 100644 tests/unit/notificationSlice.test.ts create mode 100644 tests/unit/pushSubscriptionManager.test.ts create mode 100644 tests/unit/topicRouter.test.ts diff --git a/public/sw.js b/public/sw.js index 11eb0cd..fbe836f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -174,3 +174,116 @@ self.addEventListener("fetch", (event) => { } // Everything else: let the browser handle it normally }); + +/* ============================================================ + * Push notification router + * - Foreground client present → postMessage (in-app toast) + * - Background → system Notification, coalesced + * within a 60s window via a shared tag (the coalescence key). + * ============================================================ */ + +const COALESCENCE_BODY_PREFIX = 80; +const MAX_NOTIFICATION_ACTIONS = 2; + +/* Mirror of src/utils/topicRouter.ts coalescenceKey (the SW cannot import it). */ +function coalescenceKey(topic, body) { + return `${topic} ${(body || "").slice(0, COALESCENCE_BODY_PREFIX)}`; +} + +async function handlePush(event) { + let payload = null; + try { + payload = event.data ? event.data.json() : null; + } catch { + payload = null; + } + if (!payload || typeof payload.topic !== "string") return; + + const { topic, title = "Notification", body = "", data = {}, actions = [] } = + payload; + const key = coalescenceKey(topic, body); + + const clientList = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + + // Foreground detection: route to the in-app toast queue instead of the OS. + const hasVisibleClient = clientList.some( + (c) => c.visibilityState === "visible" || c.focused + ); + if (hasVisibleClient) { + for (const client of clientList) { + client.postMessage({ type: "push-notification", coalescenceKey: key, payload }); + } + return; + } + + // Background: coalesce identical events sharing the tag into one notification. + const existing = await self.registration.getNotifications({ tag: key }); + const prevCount = + existing.length && existing[0].data ? existing[0].data.count || 1 : 0; + const count = prevCount + 1; + + await self.registration.showNotification( + count > 1 ? `${title} (${count})` : title, + { + body, + tag: key, // identical tag replaces the prior notification (coalescence) + renotify: count > 1, + icon: "/icon-192.png", + badge: "/icon-192.png", + data: { ...data, topic, count, actions, coalescenceKey: key }, + actions: (actions || []) + .slice(0, MAX_NOTIFICATION_ACTIONS) + .map((a) => ({ action: a.action, title: a.title, icon: a.icon })), + } + ); +} + +self.addEventListener("push", (event) => { + event.waitUntil(handlePush(event)); +}); + +/* ---------- Notification click: route to URL or app route ---------- */ +function resolveTarget(notificationData, actionId) { + const actions = (notificationData && notificationData.actions) || []; + if (actionId) { + const matched = actions.find((a) => a.action === actionId); + if (matched) return matched.url || matched.route || "/"; + } + const first = actions[0]; + return (first && (first.url || first.route)) || "/"; +} + +async function openOrFocus(target, payload) { + const clientList = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + for (const client of clientList) { + if ("focus" in client) { + await client.focus(); + client.postMessage({ type: "notification-action", target, payload }); + return; + } + } + if (self.clients.openWindow) { + await self.clients.openWindow(target); + } +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const data = event.notification.data || {}; + const target = resolveTarget(data, event.action); + event.waitUntil(openOrFocus(target, data)); +}); + +/* ---------- Control messages from clients ---------- */ +self.addEventListener("message", (event) => { + const msg = event.data; + if (msg && msg.type === "skip-waiting") { + self.skipWaiting(); + } +}); diff --git a/src/components/panels/ToastContainer.tsx b/src/components/panels/ToastContainer.tsx new file mode 100644 index 0000000..01eb05a --- /dev/null +++ b/src/components/panels/ToastContainer.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useNotifications, notificationStore } from "@/store/slices/notificationSlice"; +import { + MAX_NOTIFICATION_ACTIONS, + type AppNotification, + type NotificationAction, +} from "@/types/notification"; + +/** + * In-app toast renderer. Reads the coalesced notification queue and shows one + * toast per coalescence key, suffixing "(N more)" when a key has coalesced more + * than once. Action buttons map to a URL or internal route (max 2). + */ + +export interface ToastContainerProps { + /** Navigate to an internal app route (e.g. router.push). */ + onNavigate?: (route: string) => void; + /** Called when an action button is pressed, before navigation. */ + onAction?: (notification: AppNotification, action: NotificationAction) => void; + className?: string; +} + +function topicAccent(topic: string): string { + const domain = topic.split(".")[0]; + switch (domain) { + case "meter": + return "border-l-blue-500"; + case "contract": + return "border-l-amber-500"; + case "system": + return "border-l-red-500"; + default: + return "border-l-border"; + } +} + +export function ToastContainer({ + onNavigate, + onAction, + className, +}: ToastContainerProps) { + const notifications = useNotifications(); + + if (notifications.length === 0) return null; + + const handleAction = ( + notification: AppNotification, + action: NotificationAction + ) => { + onAction?.(notification, action); + if (action.route) { + onNavigate?.(action.route); + } else if (action.url && typeof window !== "undefined") { + window.open(action.url, "_blank", "noopener,noreferrer"); + } + notificationStore.dismiss(notification.id); + }; + + return ( +
+ {notifications.map((n) => ( +
+
+
+

+ {n.title} + {n.count > 1 && ( + + ({n.count - 1} more) + + )} +

+

+ {n.body} +

+

+ {n.topic} +

+
+ +
+ + {n.actions && n.actions.length > 0 && ( +
+ {n.actions.slice(0, MAX_NOTIFICATION_ACTIONS).map((action) => ( + + ))} +
+ )} +
+ ))} +
+ ); +} + +export default ToastContainer; diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..151c189 --- /dev/null +++ b/src/hooks/usePushNotifications.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getActiveSubscription, + isPushSupported, + subscribe as subscribePush, + unsubscribe as unsubscribePush, + type PushSubscriptionManagerDeps, +} from "@/services/pushSubscriptionManager"; +import { topicRouter } from "@/utils/topicRouter"; +import { notificationStore, useNotifications } from "@/store/slices/notificationSlice"; +import type { + AppNotification, + PushTopicHandler, + SwPushMessage, +} from "@/types/notification"; + +export type PushPermission = NotificationPermission | "unsupported"; + +export interface UsePushNotificationsOptions { + /** Inject subscription-manager dependencies (tests / custom transport). */ + deps?: PushSubscriptionManagerDeps; + /** Subscribe automatically once permission is granted. @default false */ + autoSubscribe?: boolean; +} + +export interface UsePushNotificationsResult { + permission: PushPermission; + isSubscribed: boolean; + supported: boolean; + notifications: AppNotification[]; + requestPermission: () => Promise; + subscribe: () => Promise; + unsubscribe: () => Promise; + /** Register a handler for a topic/pattern. Returns an unsubscribe function. */ + registerTopic: (pattern: string, handler: PushTopicHandler) => () => void; + dismiss: (key: string) => void; +} + +function readPermission(): PushPermission { + if (typeof Notification === "undefined") return "unsupported"; + return Notification.permission; +} + +function isSwPushMessage(data: unknown): data is SwPushMessage { + return ( + typeof data === "object" && + data !== null && + (data as { type?: unknown }).type === "push-notification" + ); +} + +/** + * Orchestrates push notifications: requests permission, registers the push + * subscription, and wires foreground delivery. When the SW detects a visible + * client it posts the event here instead of showing a system notification; the + * hook fans it out to topic handlers and the in-app toast queue. + */ +export function usePushNotifications( + options: UsePushNotificationsOptions = {} +): UsePushNotificationsResult { + const { deps, autoSubscribe = false } = options; + const supported = typeof window !== "undefined" && isPushSupported(); + + const [permission, setPermission] = useState(() => + typeof window === "undefined" ? "unsupported" : readPermission() + ); + const [isSubscribed, setIsSubscribed] = useState(false); + const notifications = useNotifications(); + + const depsRef = useRef(deps); + depsRef.current = deps; + + // Foreground delivery: the SW posts coalesced push events to this client. + useEffect(() => { + if (typeof navigator === "undefined" || !navigator.serviceWorker) return; + const onMessage = (event: MessageEvent) => { + if (!isSwPushMessage(event.data)) return; + const { payload } = event.data; + topicRouter.emit(payload); + notificationStore.receive(payload); + }; + navigator.serviceWorker.addEventListener("message", onMessage); + return () => + navigator.serviceWorker.removeEventListener("message", onMessage); + }, []); + + // Reflect any pre-existing subscription on mount. + useEffect(() => { + let cancelled = false; + if (!supported) return; + getActiveSubscription(depsRef.current) + .then((sub) => { + if (!cancelled) setIsSubscribed(sub !== null); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [supported]); + + const requestPermission = useCallback(async (): Promise => { + if (typeof Notification === "undefined") return "unsupported"; + const result = await Notification.requestPermission(); + setPermission(result); + return result; + }, []); + + const subscribe = useCallback(async (): Promise => { + if (!supported) return false; + let perm = readPermission(); + if (perm === "default") perm = await requestPermission(); + if (perm !== "granted") { + setPermission(perm); + return false; + } + try { + await subscribePush(depsRef.current); + setIsSubscribed(true); + return true; + } catch { + setIsSubscribed(false); + return false; + } + }, [supported, requestPermission]); + + const unsubscribe = useCallback(async (): Promise => { + const result = await unsubscribePush(depsRef.current); + if (result) setIsSubscribed(false); + return result; + }, []); + + const registerTopic = useCallback( + (pattern: string, handler: PushTopicHandler) => + topicRouter.insert(pattern, handler), + [] + ); + + const dismiss = useCallback((key: string) => { + notificationStore.dismiss(key); + }, []); + + // Optional auto-subscribe once permission is already granted. + useEffect(() => { + if (autoSubscribe && supported && permission === "granted" && !isSubscribed) { + void subscribe(); + } + }, [autoSubscribe, supported, permission, isSubscribed, subscribe]); + + return { + permission, + isSubscribed, + supported, + notifications, + requestPermission, + subscribe, + unsubscribe, + registerTopic, + dismiss, + }; +} diff --git a/src/services/pushSubscriptionManager.ts b/src/services/pushSubscriptionManager.ts new file mode 100644 index 0000000..1131196 --- /dev/null +++ b/src/services/pushSubscriptionManager.ts @@ -0,0 +1,136 @@ +"use client"; + +/** + * Registers and unregisters Web Push subscriptions with the backend. + * + * `subscribe()` asks the active service worker's PushManager for a subscription + * (VAPID `applicationServerKey`, `userVisibleOnly: true`) and POSTs it to the + * server; `unsubscribe()` tears it down and DELETEs it. Transport and the + * registration source are injectable so the flow is testable without a real SW. + */ + +export interface PushSubscriptionManagerDeps { + /** Resolve the active service worker registration. */ + getRegistration?: () => Promise; + fetchFn?: typeof fetch; + /** Base64url VAPID public key. Defaults to NEXT_PUBLIC_VAPID_PUBLIC_KEY. */ + applicationServerKey?: string; + /** REST base path. @default "/api/push" */ + apiBase?: string; +} + +/** Decode a base64url VAPID key into the Uint8Array the PushManager expects. */ +export function urlBase64ToUint8Array(base64: string): Uint8Array { + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(normalized); + const output = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + output[i] = raw.charCodeAt(i); + } + return output; +} + +function resolveDeps(deps: PushSubscriptionManagerDeps = {}) { + return { + getRegistration: + deps.getRegistration ?? (() => navigator.serviceWorker.ready), + fetchFn: deps.fetchFn ?? fetch.bind(globalThis), + applicationServerKey: + deps.applicationServerKey ?? process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + apiBase: deps.apiBase ?? "/api/push", + }; +} + +export class PushNotSupportedError extends Error { + constructor() { + super("Push messaging is not supported in this environment"); + this.name = "PushNotSupportedError"; + } +} + +/** True when this environment can register push subscriptions. */ +export function isPushSupported(): boolean { + return ( + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + typeof window !== "undefined" && + "PushManager" in window && + "Notification" in window + ); +} + +/** + * Ensure an active push subscription exists and is registered with the backend. + * Reuses an existing subscription when present (idempotent). + */ +export async function subscribe( + deps: PushSubscriptionManagerDeps = {} +): Promise { + if (!isPushSupported()) throw new PushNotSupportedError(); + const { getRegistration, fetchFn, applicationServerKey, apiBase } = + resolveDeps(deps); + if (!applicationServerKey) { + throw new Error("Missing VAPID applicationServerKey"); + } + + const registration = await getRegistration(); + const existing = await registration.pushManager.getSubscription(); + const subscription = + existing ?? + (await registration.pushManager.subscribe({ + userVisibleOnly: true, + // Cast: the DOM lib types the key as BufferSource; the generic Uint8Array + // returned here is structurally compatible but not auto-assignable. + applicationServerKey: urlBase64ToUint8Array( + applicationServerKey + ) as unknown as BufferSource, + })); + + const res = await fetchFn(`${apiBase}/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }); + if (!res.ok) { + throw new Error(`Failed to register subscription: HTTP ${res.status}`); + } + return subscription; +} + +/** + * Remove the active subscription from the backend and the browser. Resolves + * `true` if a subscription was torn down, `false` if there was none. + */ +export async function unsubscribe( + deps: PushSubscriptionManagerDeps = {} +): Promise { + if (!isPushSupported()) return false; + const { getRegistration, fetchFn, apiBase } = resolveDeps(deps); + + const registration = await getRegistration(); + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) return false; + + // Best-effort backend cleanup before dropping the local subscription. + try { + await fetchFn(`${apiBase}/subscribe`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + } catch { + // proceed with local unsubscribe regardless + } + return subscription.unsubscribe(); +} + +/** Return the current subscription, or null if none / unsupported. */ +export async function getActiveSubscription( + deps: PushSubscriptionManagerDeps = {} +): Promise { + if (!isPushSupported()) return null; + const { getRegistration } = resolveDeps(deps); + const registration = await getRegistration(); + return registration.pushManager.getSubscription(); +} diff --git a/src/store/slices/notificationSlice.ts b/src/store/slices/notificationSlice.ts new file mode 100644 index 0000000..4f82fbd --- /dev/null +++ b/src/store/slices/notificationSlice.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { coalescenceKey } from "@/utils/topicRouter"; +import { + COALESCENCE_WINDOW_MS, + MAX_NOTIFICATION_ACTIONS, + type AppNotification, + type PushPayload, +} from "@/types/notification"; + +/** + * In-app notification queue with 60-second coalescence. + * + * Identical events (same {@link coalescenceKey}) within the window increment a + * count on the existing notification and extend its auto-dismiss timer instead + * of creating duplicates. Implemented as a custom store (matching the codebase + * pattern) consumed via {@link useNotifications}. Timers are injectable so the + * coalescence behaviour is testable with fake clocks. + */ + +interface Entry { + notification: AppNotification; + timer: ReturnType | null; +} + +export interface NotificationStoreDeps { + setTimeoutFn?: (cb: () => void, ms: number) => ReturnType; + clearTimeoutFn?: (id: ReturnType) => void; + now?: () => number; + window?: number; +} + +type Listener = (notifications: AppNotification[]) => void; + +const EMPTY: AppNotification[] = []; + +export class NotificationStore { + private entries = new Map(); + private listeners = new Set(); + /** Cached, referentially-stable snapshot for useSyncExternalStore. */ + private snapshot: AppNotification[] = EMPTY; + + private readonly setTimeoutFn: NonNullable; + private readonly clearTimeoutFn: NonNullable; + private readonly now: () => number; + private readonly window: number; + + constructor(deps: NotificationStoreDeps = {}) { + this.setTimeoutFn = + deps.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms)); + this.clearTimeoutFn = deps.clearTimeoutFn ?? ((id) => clearTimeout(id)); + this.now = deps.now ?? Date.now; + this.window = deps.window ?? COALESCENCE_WINDOW_MS; + } + + getState = (): AppNotification[] => this.snapshot; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** + * Ingest a push payload. Coalesces into an existing entry when one with the + * same key is live, otherwise creates a new one. Returns the coalescence key. + */ + receive(payload: PushPayload): string { + const key = coalescenceKey(payload.topic, payload.body); + const t = this.now(); + const existing = this.entries.get(key); + + if (existing) { + if (existing.timer !== null) this.clearTimeoutFn(existing.timer); + existing.notification = { + ...existing.notification, + // Refresh display fields to the latest occurrence. + title: payload.title, + body: payload.body, + data: payload.data, + actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS), + lastSeenAt: t, + count: existing.notification.count + 1, + }; + existing.timer = this.arm(key); + } else { + const notification: AppNotification = { + id: key, + topic: payload.topic, + title: payload.title, + body: payload.body, + data: payload.data, + actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS), + firstSeenAt: t, + lastSeenAt: t, + count: 1, + }; + this.entries.set(key, { notification, timer: this.arm(key) }); + } + + this.commit(); + return key; + } + + /** Manually dismiss a notification (e.g. operator acknowledged it). */ + dismiss(key: string): void { + const entry = this.entries.get(key); + if (!entry) return; + if (entry.timer !== null) this.clearTimeoutFn(entry.timer); + this.entries.delete(key); + this.commit(); + } + + /** Remove all notifications and cancel their timers. */ + clear(): void { + for (const entry of this.entries.values()) { + if (entry.timer !== null) this.clearTimeoutFn(entry.timer); + } + this.entries.clear(); + this.commit(); + } + + /** Current coalescence count for a key (0 if absent). */ + countFor(key: string): number { + return this.entries.get(key)?.notification.count ?? 0; + } + + private arm(key: string): ReturnType { + return this.setTimeoutFn(() => this.dismiss(key), this.window); + } + + /** Recompute the cached snapshot (newest first) and notify subscribers. */ + private commit(): void { + const list = Array.from(this.entries.values(), (e) => e.notification); + list.sort((a, b) => b.lastSeenAt - a.lastSeenAt); + this.snapshot = list.length === 0 ? EMPTY : list; + for (const listener of this.listeners) listener(this.snapshot); + } +} + +/** Shared singleton notification store. */ +export const notificationStore = new NotificationStore(); + +/** React binding: subscribe a component to the coalesced notification queue. */ +export function useNotifications(): AppNotification[] { + return useSyncExternalStore( + notificationStore.subscribe, + notificationStore.getState, + notificationStore.getState + ); +} diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 0000000..dc2b12d --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,83 @@ +/** + * Types and invariants for the push-notification router with a topic-based + * subscription registry. + * + * Push events carry a three-level topic (`domain.subdomain.action`, e.g. + * `meter.water.breach`). UI modules register handlers against exact topics or + * trailing-wildcard ancestors (`meter.water.*`, `meter.*`, `*`). Identical + * events are coalesced within a 60-second window. In the foreground the service + * worker routes to an in-app toast queue instead of the system Notification API. + */ + +/** Topic taxonomy depth (domain.subdomain.action). */ +export const TOPIC_MAX_DEPTH = 3; +/** Maximum distinct registered topics/patterns. */ +export const MAX_SUBSCRIPTIONS = 200; +/** Maximum handler callbacks per topic/pattern. */ +export const MAX_HANDLERS_PER_TOPIC = 5; +/** Coalescence window: identical events within this window are merged (ms). */ +export const COALESCENCE_WINDOW_MS = 60_000; +/** Chrome desktop caps notification action buttons at 2. */ +export const MAX_NOTIFICATION_ACTIONS = 2; +/** Browser push payload size limit (bytes). */ +export const PUSH_PAYLOAD_LIMIT_BYTES = 4096; +/** Number of body characters folded into the coalescence key. */ +export const COALESCENCE_BODY_PREFIX = 80; + +/** An actionable notification button. Maps to a URL or an internal app route. */ +export interface NotificationAction { + /** Stable action id echoed back on click (e.g. "acknowledge", "view-map"). */ + action: string; + /** Button label, e.g. "Acknowledge". */ + title: string; + /** External URL to open on click. */ + url?: string; + /** Internal app route to navigate to on click. */ + route?: string; + /** Optional icon URL. */ + icon?: string; +} + +/** The push payload delivered over the wire (≤ 4 KB). */ +export interface PushPayload { + /** Three-level topic, e.g. "contract.execution.reverted". */ + topic: string; + title: string; + body: string; + /** Optional structured data (reserved within the 4 KB budget). */ + data?: Record; + /** Up to {@link MAX_NOTIFICATION_ACTIONS} action buttons. */ + actions?: NotificationAction[]; +} + +/** Handler registered against a topic/pattern in the router. */ +export type PushTopicHandler = (payload: PushPayload) => void; + +/** A coalesced, in-app notification rendered by the toast container. */ +export interface AppNotification { + /** Stable id (the coalescence key). */ + id: string; + topic: string; + title: string; + body: string; + data?: Record; + actions?: NotificationAction[]; + /** First time this coalescence key was seen (ms). */ + firstSeenAt: number; + /** Most recent occurrence (ms). */ + lastSeenAt: number; + /** Number of coalesced occurrences (≥ 1). */ + count: number; +} + +/** Message the service worker posts to a foreground client. */ +export interface SwPushMessage { + type: "push-notification"; + coalescenceKey: string; + payload: PushPayload; +} + +/** Message a client posts to the SW (e.g. to acknowledge). */ +export type ClientSwMessage = + | { type: "ack"; coalescenceKey: string } + | { type: "skip-waiting" }; diff --git a/src/utils/topicRouter.ts b/src/utils/topicRouter.ts new file mode 100644 index 0000000..a5cbdb7 --- /dev/null +++ b/src/utils/topicRouter.ts @@ -0,0 +1,180 @@ +/** + * Topic trie for routing push events to registered handlers. + * + * Subscriptions are keyed by a topic pattern: an exact topic + * (`meter.water.breach`) or a trailing-wildcard ancestor (`meter.water.*`, + * `meter.*`, `*`). A trailing `*` matches one or more remaining segments, so + * `meter.*` matches `meter.water.breach`. Matching collects handlers from the + * exact path and every wildcard ancestor. + */ + +import { + COALESCENCE_BODY_PREFIX, + MAX_HANDLERS_PER_TOPIC, + MAX_SUBSCRIPTIONS, + TOPIC_MAX_DEPTH, + type PushPayload, + type PushTopicHandler, +} from "@/types/notification"; + +const SEGMENT_RE = /^[a-z0-9_-]+$/; + +/** Validate a subscription pattern (segments + optional trailing wildcard). */ +export function isValidPattern(pattern: string): boolean { + if (!pattern) return false; + const segments = pattern.split("."); + if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (seg === "*") { + // A wildcard is only valid as the final segment. + if (i !== segments.length - 1) return false; + continue; + } + if (!SEGMENT_RE.test(seg)) return false; + } + return true; +} + +/** Validate a concrete (non-wildcard) topic. */ +export function isValidTopic(topic: string): boolean { + if (!topic) return false; + const segments = topic.split("."); + if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false; + return segments.every((s) => SEGMENT_RE.test(s)); +} + +/** + * Coalescence key: identical events within the window share `topic` plus the + * first {@link COALESCENCE_BODY_PREFIX} chars of the body. Mirrored inline in + * `public/sw.js` (the SW cannot import this module). + */ +export function coalescenceKey(topic: string, body: string): string { + return `${topic} ${(body ?? "").slice(0, COALESCENCE_BODY_PREFIX)}`; +} + +interface TrieNode { + children: Map; + handlers: Set; +} + +function createNode(): TrieNode { + return { children: new Map(), handlers: new Set() }; +} + +export class TopicRouter { + private readonly root = createNode(); + /** Number of distinct patterns currently holding at least one handler. */ + private patternCount = 0; + + /** Distinct subscribed patterns. */ + get size(): number { + return this.patternCount; + } + + /** + * Register `handler` for `pattern`. Returns an unsubscribe function. Throws if + * the subscription or per-topic handler limits would be exceeded. + */ + insert(pattern: string, handler: PushTopicHandler): () => void { + if (!isValidPattern(pattern)) { + throw new Error(`Invalid topic pattern: "${pattern}"`); + } + + const segments = pattern.split("."); + let node = this.root; + for (const seg of segments) { + let child = node.children.get(seg); + if (!child) { + child = createNode(); + node.children.set(seg, child); + } + node = child; + } + + const isNewPattern = node.handlers.size === 0; + if (isNewPattern && this.patternCount >= MAX_SUBSCRIPTIONS) { + throw new Error( + `Subscription limit reached (${MAX_SUBSCRIPTIONS} topics)` + ); + } + if (node.handlers.size >= MAX_HANDLERS_PER_TOPIC) { + throw new Error( + `Handler limit reached for "${pattern}" (${MAX_HANDLERS_PER_TOPIC})` + ); + } + + node.handlers.add(handler); + if (isNewPattern) this.patternCount += 1; + + let active = true; + return () => { + if (!active) return; + active = false; + if (node.handlers.delete(handler) && node.handlers.size === 0) { + this.patternCount -= 1; + } + }; + } + + /** All handlers matching `topic` (exact path + wildcard ancestors). */ + match(topic: string): PushTopicHandler[] { + if (!isValidTopic(topic)) return []; + const segments = topic.split("."); + const out: PushTopicHandler[] = []; + this.collect(this.root, segments, 0, out); + return out; + } + + private collect( + node: TrieNode, + segments: string[], + index: number, + out: PushTopicHandler[] + ): void { + if (index === segments.length) { + // Exact match terminates here. + for (const h of node.handlers) out.push(h); + return; + } + + // A trailing wildcard child matches the remaining (≥1) segments. + const star = node.children.get("*"); + if (star) { + for (const h of star.handlers) out.push(h); + } + + // Descend the exact segment path. + const exact = node.children.get(segments[index]); + if (exact) { + this.collect(exact, segments, index + 1, out); + } + } + + /** + * Dispatch `payload` to every matching handler. Handler exceptions are + * isolated so one bad subscriber cannot break delivery to the others. + * Returns the number of handlers invoked. + */ + emit(payload: PushPayload): number { + const handlers = this.match(payload.topic); + for (const handler of handlers) { + try { + handler(payload); + } catch { + // isolate handler failures + } + } + return handlers.length; + } + + /** Remove all subscriptions. */ + clear(): void { + this.root.children.clear(); + this.root.handlers.clear(); + this.patternCount = 0; + } +} + +/** Shared singleton router for the app. */ +export const topicRouter = new TopicRouter(); diff --git a/tests/unit/notificationSlice.test.ts b/tests/unit/notificationSlice.test.ts new file mode 100644 index 0000000..fd8e69b --- /dev/null +++ b/tests/unit/notificationSlice.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { NotificationStore } from "@/store/slices/notificationSlice"; +import type { PushPayload } from "@/types/notification"; + +/** A controllable scheduler so coalescence timers can be driven deterministically. */ +function fakeScheduler() { + let nextId = 1; + let clock = 0; + const timers = new Map void }>(); + return { + now: () => clock, + setTimeoutFn: (cb: () => void, ms: number) => { + const id = nextId++; + timers.set(id, { fireAt: clock + ms, cb }); + return id as unknown as ReturnType; + }, + clearTimeoutFn: (id: ReturnType) => { + timers.delete(id as unknown as number); + }, + advance: (ms: number) => { + clock += ms; + for (const [id, t] of [...timers.entries()]) { + if (t.fireAt <= clock) { + timers.delete(id); + t.cb(); + } + } + }, + pending: () => timers.size, + }; +} + +function makeStore(sched: ReturnType) { + return new NotificationStore({ + setTimeoutFn: sched.setTimeoutFn, + clearTimeoutFn: sched.clearTimeoutFn, + now: sched.now, + window: 60_000, + }); +} + +function payload(topic: string, body: string, extra?: Partial): PushPayload { + return { topic, title: "Title", body, ...extra }; +} + +describe("NotificationStore coalescence", () => { + it("creates a notification with count 1", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + const key = store.receive(payload("meter.water.breach", "leak")); + const state = store.getState(); + expect(state).toHaveLength(1); + expect(state[0].id).toBe(key); + expect(state[0].count).toBe(1); + }); + + it("coalesces identical events into a single incremented entry", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive(payload("meter.water.breach", "leak")); + store.receive(payload("meter.water.breach", "leak")); + store.receive(payload("meter.water.breach", "leak")); + const state = store.getState(); + expect(state).toHaveLength(1); + expect(state[0].count).toBe(3); + }); + + it("keeps distinct keys separate", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive(payload("meter.water.breach", "leak")); + store.receive(payload("contract.execution.reverted", "tx failed")); + expect(store.getState()).toHaveLength(2); + }); + + it("auto-dismisses after the coalescence window", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive(payload("system.health.cpu", "spike")); + expect(store.getState()).toHaveLength(1); + sched.advance(60_000); + expect(store.getState()).toHaveLength(0); + }); + + it("extends the window on each repeat occurrence", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive(payload("system.health.cpu", "spike")); + sched.advance(40_000); // not yet expired + store.receive(payload("system.health.cpu", "spike")); // resets the 60s timer + sched.advance(40_000); // 80s since first, but only 40s since last + expect(store.getState()).toHaveLength(1); + expect(store.getState()[0].count).toBe(2); + sched.advance(20_000); // now 60s since last + expect(store.getState()).toHaveLength(0); + }); + + it("dismiss removes the entry and cancels its timer", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + const key = store.receive(payload("meter.water.breach", "leak")); + store.dismiss(key); + expect(store.getState()).toHaveLength(0); + expect(sched.pending()).toBe(0); + }); + + it("caps actions at two", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive( + payload("meter.water.breach", "leak", { + actions: [ + { action: "a", title: "A" }, + { action: "b", title: "B" }, + { action: "c", title: "C" }, + ], + }) + ); + expect(store.getState()[0].actions).toHaveLength(2); + }); + + it("returns a stable snapshot reference when unchanged", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + const a = store.getState(); + const b = store.getState(); + expect(a).toBe(b); + }); + + it("clear() empties the queue and cancels timers", () => { + const sched = fakeScheduler(); + const store = makeStore(sched); + store.receive(payload("meter.water.breach", "leak")); + store.receive(payload("system.health.cpu", "spike")); + store.clear(); + expect(store.getState()).toHaveLength(0); + expect(sched.pending()).toBe(0); + }); +}); diff --git a/tests/unit/pushSubscriptionManager.test.ts b/tests/unit/pushSubscriptionManager.test.ts new file mode 100644 index 0000000..4fe2e06 --- /dev/null +++ b/tests/unit/pushSubscriptionManager.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + urlBase64ToUint8Array, + subscribe, + unsubscribe, + isPushSupported, +} from "@/services/pushSubscriptionManager"; + +// --- Make isPushSupported() return true in jsdom --------------------------- +beforeEach(() => { + Object.defineProperty(window, "PushManager", { value: class {}, configurable: true }); + Object.defineProperty(window, "Notification", { value: class {}, configurable: true }); + Object.defineProperty(globalThis, "Notification", { + value: class {}, + configurable: true, + }); + Object.defineProperty(navigator, "serviceWorker", { + value: {}, + configurable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function fakeSubscription(endpoint = "https://push.example/abc") { + return { + endpoint, + toJSON: () => ({ endpoint, keys: { p256dh: "k", auth: "a" } }), + unsubscribe: vi.fn().mockResolvedValue(true), + } as unknown as PushSubscription; +} + +function fakeRegistration(existing: PushSubscription | null) { + const subscribeMock = vi.fn().mockResolvedValue(fakeSubscription()); + return { + registration: { + pushManager: { + getSubscription: vi.fn().mockResolvedValue(existing), + subscribe: subscribeMock, + }, + } as unknown as ServiceWorkerRegistration, + subscribeMock, + }; +} + +describe("urlBase64ToUint8Array", () => { + it("decodes a base64url key with padding and url-safe chars", () => { + // "AAAA" → 3 zero bytes; verifies padding handling and length. + const out = urlBase64ToUint8Array("AAAA"); + expect(out).toEqual(new Uint8Array([0, 0, 0])); + }); + + it("maps url-safe '-' and '_' to '+' and '/'", () => { + const std = urlBase64ToUint8Array("a-b_"); + const expected = (() => { + const raw = atob("a+b/"); + return new Uint8Array([...raw].map((c) => c.charCodeAt(0))); + })(); + expect(std).toEqual(expected); + }); +}); + +describe("isPushSupported", () => { + it("is true once SW/PushManager/Notification are present", () => { + expect(isPushSupported()).toBe(true); + }); +}); + +describe("subscribe", () => { + it("creates a subscription and POSTs it to the backend", async () => { + const { registration, subscribeMock } = fakeRegistration(null); + const fetchFn = vi.fn().mockResolvedValue({ ok: true }); + + const sub = await subscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: fetchFn as unknown as typeof fetch, + applicationServerKey: "AAAA", + }); + + expect(subscribeMock).toHaveBeenCalledWith( + expect.objectContaining({ userVisibleOnly: true }) + ); + expect(fetchFn).toHaveBeenCalledWith( + "/api/push/subscribe", + expect.objectContaining({ method: "POST" }) + ); + expect(sub.endpoint).toContain("push.example"); + }); + + it("reuses an existing subscription (idempotent)", async () => { + const existing = fakeSubscription("https://push.example/existing"); + const { registration, subscribeMock } = fakeRegistration(existing); + const fetchFn = vi.fn().mockResolvedValue({ ok: true }); + + const sub = await subscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: fetchFn as unknown as typeof fetch, + applicationServerKey: "AAAA", + }); + + expect(subscribeMock).not.toHaveBeenCalled(); + expect(sub.endpoint).toBe("https://push.example/existing"); + }); + + it("throws when the VAPID key is missing", async () => { + const { registration } = fakeRegistration(null); + await expect( + subscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: vi.fn() as unknown as typeof fetch, + applicationServerKey: "", + }) + ).rejects.toThrow(/VAPID/); + }); + + it("throws when the backend rejects the subscription", async () => { + const { registration } = fakeRegistration(null); + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + await expect( + subscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: fetchFn as unknown as typeof fetch, + applicationServerKey: "AAAA", + }) + ).rejects.toThrow(/HTTP 500/); + }); +}); + +describe("unsubscribe", () => { + it("DELETEs the subscription and tears it down locally", async () => { + const existing = fakeSubscription("https://push.example/x"); + const { registration } = fakeRegistration(existing); + const fetchFn = vi.fn().mockResolvedValue({ ok: true }); + + const result = await unsubscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "/api/push/subscribe", + expect.objectContaining({ method: "DELETE" }) + ); + expect(existing.unsubscribe).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("returns false when there is no active subscription", async () => { + const { registration } = fakeRegistration(null); + const result = await unsubscribe({ + getRegistration: () => Promise.resolve(registration), + fetchFn: vi.fn() as unknown as typeof fetch, + }); + expect(result).toBe(false); + }); +}); diff --git a/tests/unit/topicRouter.test.ts b/tests/unit/topicRouter.test.ts new file mode 100644 index 0000000..697d4bd --- /dev/null +++ b/tests/unit/topicRouter.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi } from "vitest"; +import { + TopicRouter, + coalescenceKey, + isValidPattern, + isValidTopic, +} from "@/utils/topicRouter"; +import { + MAX_HANDLERS_PER_TOPIC, + MAX_SUBSCRIPTIONS, + type PushPayload, +} from "@/types/notification"; + +function payload(topic: string, body = "b"): PushPayload { + return { topic, title: "t", body }; +} + +describe("pattern / topic validation", () => { + it("accepts up to three valid segments", () => { + expect(isValidTopic("meter.water.breach")).toBe(true); + expect(isValidPattern("meter.water.breach")).toBe(true); + }); + + it("accepts a trailing wildcard pattern", () => { + expect(isValidPattern("meter.water.*")).toBe(true); + expect(isValidPattern("meter.*")).toBe(true); + expect(isValidPattern("*")).toBe(true); + }); + + it("rejects a non-trailing wildcard", () => { + expect(isValidPattern("meter.*.breach")).toBe(false); + }); + + it("rejects more than three segments and bad characters", () => { + expect(isValidTopic("a.b.c.d")).toBe(false); + expect(isValidTopic("Meter.Water")).toBe(false); + expect(isValidTopic("meter water")).toBe(false); + }); +}); + +describe("TopicRouter matching", () => { + it("matches an exact topic", () => { + const r = new TopicRouter(); + const h = vi.fn(); + r.insert("meter.water.breach", h); + expect(r.match("meter.water.breach")).toContain(h); + expect(r.match("meter.water.other")).not.toContain(h); + }); + + it("matches via a same-level trailing wildcard", () => { + const r = new TopicRouter(); + const h = vi.fn(); + r.insert("meter.water.*", h); + expect(r.match("meter.water.breach")).toContain(h); + expect(r.match("meter.gas.breach")).not.toContain(h); + }); + + it("matches via a higher wildcard ancestor", () => { + const r = new TopicRouter(); + const h = vi.fn(); + r.insert("meter.*", h); + expect(r.match("meter.water.breach")).toContain(h); + expect(r.match("meter.gas.low")).toContain(h); + expect(r.match("contract.execution.reverted")).not.toContain(h); + }); + + it("a root wildcard matches everything", () => { + const r = new TopicRouter(); + const h = vi.fn(); + r.insert("*", h); + expect(r.match("system.health.cpu")).toContain(h); + expect(r.match("contract.execution.reverted")).toContain(h); + }); + + it("collects handlers from the exact path and all wildcard ancestors", () => { + const r = new TopicRouter(); + const exact = vi.fn(); + const mid = vi.fn(); + const top = vi.fn(); + const root = vi.fn(); + r.insert("meter.water.breach", exact); + r.insert("meter.water.*", mid); + r.insert("meter.*", top); + r.insert("*", root); + const matched = r.match("meter.water.breach"); + expect(matched).toEqual(expect.arrayContaining([exact, mid, top, root])); + expect(matched).toHaveLength(4); + }); + + it("does not match a wildcard against a shorter topic", () => { + const r = new TopicRouter(); + const h = vi.fn(); + r.insert("meter.water.*", h); + expect(r.match("meter.water")).not.toContain(h); + }); +}); + +describe("TopicRouter.emit", () => { + it("invokes all matching handlers with the payload", () => { + const r = new TopicRouter(); + const a = vi.fn(); + const b = vi.fn(); + r.insert("contract.execution.reverted", a); + r.insert("contract.*", b); + const p = payload("contract.execution.reverted"); + expect(r.emit(p)).toBe(2); + expect(a).toHaveBeenCalledWith(p); + expect(b).toHaveBeenCalledWith(p); + }); + + it("isolates a throwing handler from the others", () => { + const r = new TopicRouter(); + const bad = vi.fn(() => { + throw new Error("boom"); + }); + const good = vi.fn(); + r.insert("system.health.cpu", bad); + r.insert("system.health.cpu", good); + expect(() => r.emit(payload("system.health.cpu"))).not.toThrow(); + expect(good).toHaveBeenCalled(); + }); +}); + +describe("TopicRouter unsubscribe & limits", () => { + it("unsubscribe removes the handler and frees the pattern slot", () => { + const r = new TopicRouter(); + const h = vi.fn(); + const off = r.insert("meter.water.breach", h); + expect(r.size).toBe(1); + off(); + expect(r.match("meter.water.breach")).not.toContain(h); + expect(r.size).toBe(0); + off(); // idempotent + expect(r.size).toBe(0); + }); + + it("throws past the per-topic handler limit", () => { + const r = new TopicRouter(); + for (let i = 0; i < MAX_HANDLERS_PER_TOPIC; i++) { + r.insert("meter.water.breach", vi.fn()); + } + expect(() => r.insert("meter.water.breach", vi.fn())).toThrow(/Handler limit/); + }); + + it("throws past the global subscription limit", () => { + const r = new TopicRouter(); + for (let i = 0; i < MAX_SUBSCRIPTIONS; i++) { + r.insert(`meter.water.t${i}`, vi.fn()); + } + expect(r.size).toBe(MAX_SUBSCRIPTIONS); + expect(() => r.insert("meter.water.overflow", vi.fn())).toThrow( + /Subscription limit/ + ); + }); + + it("rejects an invalid pattern", () => { + const r = new TopicRouter(); + expect(() => r.insert("Bad.Topic", vi.fn())).toThrow(/Invalid topic pattern/); + }); +}); + +describe("coalescenceKey", () => { + it("combines topic with the first 80 body chars", () => { + expect(coalescenceKey("meter.water.breach", "leak detected")).toBe( + "meter.water.breach leak detected" + ); + const long = "x".repeat(200); + expect(coalescenceKey("a.b.c", long)).toBe(`a.b.c ${"x".repeat(80)}`); + }); +});