Files
MealQuest/web/src/push/notification.ts
T

175 lines
7.4 KiB
TypeScript
Raw Normal View History

// 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<string, unknown> {
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<void>
}
// 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<void> {
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<W> = { 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<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
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<unknown>
navigate(url: string): Promise<unknown>
}
export type ClickClients<W extends ClickWindow> = {
matchAll(options: { type: 'window' }): Promise<readonly W[]>
openWindow(url: string): Promise<unknown>
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
origin: string,
): Promise<void> {
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)
}