// Web Push notifications as the service worker shows them, and where a tap on // one takes the household. The worker's push and notificationclick listeners // (src/sw.ts) only adapt these to the worker's globals, so every decision runs // under test without a real ServiceWorkerGlobalScope. This file is checked // with both the DOM and the WebWorker libraries, so it names only the narrow // shapes it uses. // APP_ICON and APP_BADGE are the app icon from the web manifest. export const APP_ICON = '/icons/icon-192.png' export const APP_BADGE = '/icons/icon-192.png' // PushNotice is a notification to show: its title, body and tag, and the // same-origin path a tap opens. export type PushNotice = { title: string; body: string; tag?: string; url: string } // GENERIC_NOTICE is shown for a push whose payload is not a notice: the // household still learns that MealQuest has something for them. export const GENERIC_NOTICE: PushNotice = { title: 'MealQuest', body: "There's something new in MealQuest.", url: '/', } // ParsedPush is the notice to show for a push, and why its payload was not // used, when it was not. export type ParsedPush = { notice: PushNotice; problem: string | null } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } // appPath returns raw as a path on origin (path, query and fragment), or null // when raw is not a path-absolute URL that stays on origin. A payload never // sends the household to another site. The path is checked after the parser // has resolved it: dot segments (also as %2e), backslashes and stripped tabs // and newlines can all turn an innocent-looking path into one starting with // //, which a browser would read as another host. export function appPath(raw: string, origin: string): string | null { if (!raw.startsWith('/') || raw.startsWith('//')) return null let url: URL try { url = new URL(raw, origin) } catch { return null } if (url.origin !== origin || url.pathname.startsWith('//')) return null return url.pathname + url.search + url.hash } // parsePushPayload reads a push message's payload, which may be any bytes: // a JSON object with a non-empty string title, and optionally a string body, // a non-empty string tag and a url that is a same-origin path. Other fields // are ignored. Anything else — no payload, not JSON, a missing title, a // field of the wrong type, a url off origin — is shown as GENERIC_NOTICE, // with the problem for the worker to log. It never throws. export function parsePushPayload(text: string | null, origin: string): ParsedPush { const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem }) if (text === null || text === '') return generic('push message has no payload') let payload: unknown try { payload = JSON.parse(text) } catch { return generic('push payload is not JSON') } if (!isRecord(payload)) return generic('push payload is not a JSON object') const { title, body, tag, url } = payload if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title') if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string') if (tag !== undefined && (typeof tag !== 'string' || tag === '')) { return generic('push payload tag is not a non-empty string') } let path = '/' if (url !== undefined) { const safe = typeof url === 'string' ? appPath(url, origin) : null if (safe === null) return generic('push payload url is not a same-origin path') path = safe } const notice: PushNotice = { title, body: body ?? '', url: path } if (tag !== undefined) notice.tag = tag return { notice, problem: null } } // NoticeData is the data a shown notification carries for its click. export type NoticeData = { url: string } // notificationOptions is what showNotification is given for notice: its body // and tag, the app icon and badge, and the path to open as its data. export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } { const options: NotificationOptions & { data: NoticeData } = { body: notice.body, icon: APP_ICON, badge: APP_BADGE, data: { url: notice.url }, } if (notice.tag !== undefined) options.tag = notice.tag return options } export type NotificationShower = { showNotification(title: string, options: NotificationOptions): Promise } // showPush shows the notification for a push message's payload text, // reporting through warn why a payload was not used. export function showPush( text: string | null, registration: NotificationShower, origin: string, warn: (message: string) => void, ): Promise { const { notice, problem } = parsePushPayload(text, origin) if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`) return registration.showNotification(notice.title, notificationOptions(notice)) } // clickPath is the same-origin path a tapped notification opens: the url in // its data, which showPush put there, or the app's home when the data is not // ours (a notification shown by an older worker). export function clickPath(data: unknown, origin: string): string { if (isRecord(data) && typeof data.url === 'string') { const path = appPath(data.url, origin) if (path !== null) return path } return '/' } // AppWindow is the part of a window client a click reads. export type AppWindow = { url: string; focused: boolean } // ClickPlan is what a notification tap does: focus an open MealQuest window // and navigate it to url, or open a new window at url. url is absolute. export type ClickPlan = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string } // planClick decides a tap on a notification that opens path: the focused // MealQuest window if there is one, else the first open one, navigated to // path unless it is already there; with none open, a new window. A path that // resolves off origin is an error: a tap never leaves MealQuest. export function planClick(windows: readonly W[], path: string, origin: string): ClickPlan { const resolved = new URL(path, origin) if (resolved.origin !== origin) { throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`) } const url = resolved.href const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin) const target = ours.find((w) => w.focused) ?? ours[0] if (target === undefined) return { action: 'open', url } return { action: 'focus', window: target, url, navigate: target.url !== url } } export type ClickWindow = AppWindow & { focus(): Promise navigate(url: string): Promise } export type ClickClients = { matchAll(options: { type: 'window' }): Promise openWindow(url: string): Promise } // openNotification carries out a tap on a notification with data: focus // first, while the tap still allows it, then navigate. export async function openNotification( data: unknown, clients: ClickClients, origin: string, ): Promise { const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin) if (plan.action === 'open') { await clients.openWindow(plan.url) return } await plan.window.focus() if (plan.navigate) await plan.window.navigate(plan.url) }