Service worker push handlers and client subscribe (Feature 19) (#156)
CI / ci (push) Successful in 6m37s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #156.
This commit is contained in:
2026-10-09 11:14:20 -04:00
committed by approver_agent
parent 7a2f80cc09
commit c8e5829a77
8 changed files with 1031 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_BADGE,
APP_ICON,
GENERIC_NOTICE,
appPath,
clickPath,
openNotification,
parsePushPayload,
planClick,
showPush,
type ClickWindow,
} from './notification.ts'
const origin = 'https://mealquest.example'
describe('appPath', () => {
it('keeps a same-origin path with its query and fragment', () => {
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
})
it.each([
'https://evil.example/plan',
'//evil.example/plan',
'/\\evil.example/plan',
'javascript:alert(1)',
'plan',
'',
])('refuses %s', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
it('resolves dot segments and keeps the result on origin', () => {
expect(appPath('/plan/../discover', origin)).toBe('/discover')
})
// Each of these starts with a single / but resolves to a path starting with
// //, which a browser reads as another host.
it.each([
'/..//evil.example/path',
'/plan/..//evil.example/path',
'/..\\\\evil.example/path',
'/./\\evil.example',
'/.\\/evil.example',
'/%2e%2e//evil.example/path',
'/.\t.//evil.example/path',
'/..\n//evil.example',
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
})
describe('parsePushPayload', () => {
it('reads title, body, tag and url', () => {
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
expect(parsePushPayload(text, origin)).toEqual({
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
problem: null,
})
})
it('opens home with an empty body and no tag when only a title is sent', () => {
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
notice: { title: 'Hi', body: '', url: '/' },
problem: null,
})
})
it('ignores fields it does not know', () => {
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
expect(parsed.problem).toBeNull()
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
})
it.each([
[null, 'push message has no payload'],
['', 'push message has no payload'],
['\u0000ÿ not json', 'push payload is not JSON'],
['[1,2]', 'push payload is not a JSON object'],
['null', 'push payload is not a JSON object'],
['{"body":"no title"}', 'push payload has no title'],
['{"title":" "}', 'push payload has no title'],
['{"title":42}', 'push payload has no title'],
['{"title":"Hi","body":7}', 'push payload body is not a string'],
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
])('shows the generic notice for %j', (text, problem) => {
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
})
})
describe('showPush', () => {
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await showPush(
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
registration,
origin,
warn,
)
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
body: 'Time to shop.',
tag: 'list_ready',
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/plan/shopping' },
})
expect(warn).not.toHaveBeenCalled()
})
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
body: GENERIC_NOTICE.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/' },
})
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
})
})
describe('clickPath', () => {
it('opens the url the notification carries', () => {
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
})
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
expect(clickPath(data, origin)).toBe('/')
})
})
describe('planClick', () => {
it('refuses a path that resolves off origin', () => {
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
})
const at = (path: string, focused = false) => ({ url: origin + path, focused })
it('opens a new window when no MealQuest window is open', () => {
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('ignores windows on another origin', () => {
const other = { url: 'https://other.example/plan', focused: true }
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('focuses the focused MealQuest window and navigates it', () => {
const first = at('/')
const focused = at('/discover', true)
expect(planClick([first, focused], '/plan', origin)).toEqual({
action: 'focus',
window: focused,
url: `${origin}/plan`,
navigate: true,
})
})
it('focuses the first MealQuest window when none is focused', () => {
const first = at('/')
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
})
it('does not reload a window already at the url', () => {
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
})
})
describe('openNotification', () => {
function window(path: string, focused = false) {
return {
url: origin + path,
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
} satisfies ClickWindow
}
function clients(windows: ClickWindow[]) {
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
}
it('focuses an open window, then navigates it to the url', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/plan/shopping' }, all, origin)
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(all.openWindow).not.toHaveBeenCalled()
})
it('opens a new window at the url when none is open', async () => {
const all = clients([])
await openNotification({ url: '/discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
it('never opens a url off origin, even from tampered data', async () => {
const all = clients([])
await openNotification({ url: 'https://evil.example/' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
})
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
'never leaves origin for %j, which resolves to a protocol-relative path',
async (url) => {
const win = window('/plan', true)
const all = clients([win])
await openNotification({ url }, all, origin)
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
expect(all.openWindow).not.toHaveBeenCalled()
const none = clients([])
await openNotification({ url }, none, origin)
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
},
)
it('still opens an internal path that uses dot segments', async () => {
const all = clients([])
await openNotification({ url: '/plan/../discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
})
+174
View File
@@ -0,0 +1,174 @@
// 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)
}
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from 'vitest'
import { ApiError, type ApiRequest } from '../api/index.ts'
import {
SUBSCRIPTIONS_PATH,
VAPID_PUBLIC_KEY_PATH,
decodeVapidKey,
encodeBase64url,
PermissionGestureError,
subscribePush,
subscriptionBody,
unsubscribePush,
type BrowserPushSubscription,
type PushEnvironment,
} from './subscribe.ts'
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(length))
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
return out
}
const vapidKey = bytes(65, 4)
const vapidKeyText = encodeBase64url(vapidKey.buffer)
const p256dh = bytes(65, 4).buffer
const auth = bytes(16, 200).buffer
const endpoint = 'https://push.example/send/abc'
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
return {
endpoint: at,
options: { applicationServerKey: key },
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
unsubscribe: vi.fn(async () => true),
} satisfies BrowserPushSubscription
}
type Setup = {
permission?: NotificationPermission
answer?: NotificationPermission
active?: boolean
current?: BrowserPushSubscription | null
registered?: boolean
subscribeError?: unknown
}
function setup({
permission = 'default',
answer = 'granted',
active = true,
current = null,
registered = true,
subscribeError,
}: Setup = {}) {
const created = subscription()
const pushManager = {
getSubscription: vi.fn(async () => current),
subscribe: vi.fn(async () => {
if (subscribeError !== undefined) throw subscribeError
return created
}),
}
const notification = { permission, requestPermission: vi.fn(async () => answer) }
const env: PushEnvironment = {
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
pushManager: true,
notification,
userActivation: { isActive: active },
userAgent: 'Mozilla/5.0 Test',
}
const calls: { path: string; request?: ApiRequest }[] = []
const api = {
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
calls.push({ path, request })
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
return undefined
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
}
return { env, api, calls, pushManager, notification, created }
}
describe('decodeVapidKey', () => {
it('decodes the unpadded base64url point', () => {
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
})
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
'refuses %j',
(key) => {
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
},
)
})
describe('subscriptionBody', () => {
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
expect(body).toEqual({
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
})
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
expect(JSON.stringify(body)).not.toContain('expirationTime')
})
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
})
it('refuses a subscription without keys', () => {
const keyless = { ...subscription(), getKey: () => null }
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
})
})
describe('subscribePush', () => {
it.each([
['serviceWorker', { serviceWorker: undefined }],
['PushManager', { pushManager: false }],
['Notification', { notification: undefined }],
])('answers unsupported without %s, asking nothing', async (_, missing) => {
const { env, api, notification } = setup()
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
const { env, api, calls, pushManager, notification } = setup()
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
expect(calls).toEqual([
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
{
path: SUBSCRIPTIONS_PATH,
request: {
method: 'POST',
body: {
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
},
},
},
])
})
it('asks for permission before awaiting anything, so the tap still counts', async () => {
const { env, api, notification } = setup()
const pending = subscribePush(api, env)
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(api.apiFetch).not.toHaveBeenCalled()
await pending
})
it('refuses to ask for permission outside a tap', async () => {
const { env, api, notification } = setup({ active: false })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
const { env, api, notification } = setup()
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('does not need to observe a tap once permission is granted', async () => {
const { env, api, notification } = setup({ permission: 'granted' })
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('does not ask again once permission is granted, even outside a tap', async () => {
const { env, api, notification } = setup({ permission: 'granted', active: false })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('reports a permission denied earlier without asking or subscribing', async () => {
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a permission denied when asked, without subscribing', async () => {
const { env, api, pushManager } = setup({ answer: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a dismissed prompt', async () => {
const { env, api, pushManager } = setup({ answer: 'default' })
expect(await subscribePush(api, env)).toBe('dismissed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
})
it('reports denied when the browser refuses the subscription for permission', async () => {
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
expect(await subscribePush(api, env)).toBe('denied')
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
})
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
const cause = new DOMException('push service not available', 'AbortError')
const { env, api } = setup({ subscribeError: cause })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).not.toBeInstanceOf(DOMException)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
expect((error as Error).cause).toBe(cause)
})
it('rejects when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
})
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
const current = subscription()
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(current.unsubscribe).not.toHaveBeenCalled()
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
})
it('replaces a subscription made with an older key', async () => {
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(old.unsubscribe).toHaveBeenCalled()
expect(pushManager.subscribe).toHaveBeenCalled()
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
['GET', undefined],
['DELETE', { endpoint: 'https://push.example/send/old' }],
['POST', expect.objectContaining({ endpoint })],
])
})
it('passes on an API failure storing the subscription', async () => {
const { env, api } = setup()
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
throw refused
})
await expect(subscribePush(api, env)).rejects.toBe(refused)
})
it('rejects a VAPID key response without a public_key', async () => {
const { env, api } = setup()
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
})
})
describe('unsubscribePush', () => {
it('deletes the subscription on the server, then in the browser', async () => {
const current = subscription()
const { env, api, calls } = setup({ current })
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
expect(current.unsubscribe).toHaveBeenCalled()
})
it('answers not-subscribed without calling the server when there is no subscription', async () => {
const { env, api } = setup({ current: null })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers not-subscribed when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers unsupported without Web Push', async () => {
const { env, api } = setup()
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
})
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
const current = subscription()
const { env, api } = setup({ current })
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
expect(current.unsubscribe).not.toHaveBeenCalled()
})
it('rejects when the browser does not remove the subscription', async () => {
const current = subscription()
current.unsubscribe.mockResolvedValue(false)
const { env, api } = setup({ current })
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
})
})
+209
View File
@@ -0,0 +1,209 @@
import type { ApiClient } from '../api/client.ts'
import { isRecord } from '../api/errors.ts'
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
// (internal/push MaxUserAgentBytes).
export const MAX_USER_AGENT_BYTES = 512
// SubscribePushResult is how turning on Web Push on this device ended:
// subscribed, the browser's permission denied (now or earlier), the
// permission prompt dismissed without an answer, or a browser without Web
// Push. Any other failure rejects with an Error (an ApiError from the API).
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
// UnsubscribePushResult is how turning off Web Push on this device ended:
// its subscription removed from the browser and the server, no subscription
// to remove, or a browser without Web Push. Any other failure rejects.
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
// The parts of the browser's Push API this module uses.
export type BrowserPushSubscription = {
readonly endpoint: string
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
unsubscribe(): Promise<boolean>
}
export type BrowserPushManager = {
getSubscription(): Promise<BrowserPushSubscription | null>
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
}
// PushEnvironment is what the browser offers for Web Push. A field is
// undefined (or pushManager false) where the browser lacks it.
export type PushEnvironment = {
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
pushManager: boolean
notification:
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
| undefined
// Whether the page is handling a user gesture right now.
userActivation: { readonly isActive: boolean } | undefined
userAgent: string
}
// browserPushEnvironment reads the PushEnvironment from this window.
export function browserPushEnvironment(): PushEnvironment {
return {
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
pushManager: 'PushManager' in window,
notification: 'Notification' in window ? Notification : undefined,
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
userAgent: navigator.userAgent,
}
}
type PushApi = Pick<ApiClient, 'apiFetch'>
// PermissionGestureError is why subscribePush refused to ask for notification
// permission: no tap is active right now ('inactive'), or the browser cannot
// say whether one is ('unobservable', no navigator.userActivation).
export class PermissionGestureError extends Error {
readonly reason: 'inactive' | 'unobservable'
constructor(reason: 'inactive' | 'unobservable') {
super(
reason === 'inactive'
? 'subscribePush: notification permission can only be asked for from a tap on a button'
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
)
this.name = 'PermissionGestureError'
this.reason = reason
}
}
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
// subscription keys.
export function encodeBase64url(bytes: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
return bytes
}
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
if (current === null || current.byteLength !== key.length) return false
const bytes = new Uint8Array(current)
return bytes.every((byte, i) => byte === key[i])
}
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
// refuses unknown fields and nulls, so it is built field by field rather
// than from PushSubscription.toJSON(), whose expirationTime may be null.
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
// subscriptionBody is the body that stores subscription. The user agent only
// labels the device, so one that is blank or over the server's limit is left
// out rather than sent to be refused.
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
if (p256dh === null || auth === null) {
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
}
const body: SubscriptionBody = {
endpoint: subscription.endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
}
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
body.user_agent = userAgent
}
return body
}
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
if (!isRecord(body) || typeof body.public_key !== 'string') {
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
}
return decodeVapidKey(body.public_key)
}
// removeSubscription deletes subscription on the server, then in the
// browser. Deleting on the server first means a failure leaves both still
// subscribed, so trying again finds the subscription and finishes the job.
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
if (!(await subscription.unsubscribe())) {
throw new Error('Web Push: the browser did not remove its push subscription')
}
}
function errorName(error: unknown): string | undefined {
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
}
// subscribePush turns on Web Push on this device. Call it only from the
// household's tap on a button: when the browser has not yet been asked, it
// asks for notification permission, which browsers allow only during a user
// gesture, and it refuses to ask outside one — or where the browser cannot
// say whether a gesture is active — with a PermissionGestureError. Permission
// is asked before
// anything is awaited so the gesture still counts. With permission, it
// subscribes the browser with the server's VAPID public key — replacing a
// subscription made with an older key — and stores the subscription on the
// server. Subscribing again when already subscribed stores it again.
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
const { serviceWorker, notification } = env
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
let permission = notification.permission
if (permission === 'default') {
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
permission = await notification.requestPermission()
}
if (permission === 'denied') return 'denied'
if (permission === 'default') return 'dismissed'
const found = await serviceWorker.getRegistration()
if (found === undefined) {
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
}
const { pushManager } = found
const key = await fetchVapidKey(api)
let subscription = await pushManager.getSubscription()
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
await removeSubscription(api, subscription)
subscription = null
}
if (subscription === null) {
try {
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
} catch (error) {
if (errorName(error) === 'NotAllowedError') return 'denied'
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
}
}
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
return 'subscribed'
}
// unsubscribePush turns off Web Push on this device: it removes the
// browser's subscription on the server and in the browser. With no
// subscription there is nothing to remove, and it answers 'not-subscribed'
// without calling the server.
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
const { serviceWorker } = env
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
const found = await serviceWorker.getRegistration()
if (found === undefined) return 'not-subscribed'
const subscription = await found.pushManager.getSubscription()
if (subscription === null) return 'not-subscribed'
await removeSubscription(api, subscription)
return 'unsubscribed'
}
+29
View File
@@ -0,0 +1,29 @@
import { useMemo } from 'react'
import { useApi } from '../api/ApiContext.tsx'
import {
browserPushEnvironment,
subscribePush,
unsubscribePush,
type SubscribePushResult,
type UnsubscribePushResult,
} from './subscribe.ts'
export type Push = {
// subscribePush turns on Web Push on this device; call it only from a tap.
subscribePush(): Promise<SubscribePushResult>
// unsubscribePush turns off Web Push on this device.
unsubscribePush(): Promise<UnsubscribePushResult>
}
// usePush binds subscribePush and unsubscribePush to the app's API client
// and this browser.
export function usePush(): Push {
const api = useApi()
return useMemo(
() => ({
subscribePush: () => subscribePush(api, browserPushEnvironment()),
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
}),
[api],
)
}
+54
View File
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
})
})
// The push listeners only hand src/push/notification.ts the worker's globals;
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
// stubbed and the events are plain Events with the worker event's fields.
describe('service worker push listeners', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
async function dispatch(type: string, fields: object): Promise<void> {
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
self.dispatchEvent(
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
)
expect(pending).toHaveLength(1)
await Promise.all(pending)
}
it('shows a push message as a notification', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
vi.stubGlobal('registration', registration)
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
await dispatch('push', { data: { text: () => payload } })
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
body: 'Adjust the Draft board.',
tag: 'plan_ready',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: '/plan' },
})
})
it('shows the generic notification for a push without a payload', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal('registration', registration)
await dispatch('push', { data: null })
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
warn.mockRestore()
})
it('closes a tapped notification and opens its url', async () => {
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
vi.stubGlobal('clients', clients)
const notification = { data: { url: '/discover' }, close: vi.fn() }
await dispatch('notificationclick', { notification })
expect(notification.close).toHaveBeenCalled()
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
})
})
+16
View File
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { openNotification, showPush } from './push/notification'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
// Web Push: src/push/notification.ts decides what to show and where a tap
// goes; these listeners only hand it the worker's globals. A payload that is
// not a MealQuest notice still shows a generic notification, and the reason
// is logged.
self.addEventListener('push', (event) => {
event.waitUntil(
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
})
+10
View File
@@ -44,3 +44,13 @@ describe('service worker source', () => {
)
})
})
describe('service worker push listeners source', () => {
it('delegates push and notificationclick to src/push/notification.ts', () => {
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
expect(source).toMatch(
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
)
})
})