@codefusion-cc/web-push
v0.1.1
Published
Web Push on WebCrypto alone: VAPID keys and tokens, aes128gcm payloads and sending from a Cloudflare Worker, subscribing and showing notifications in the browser, and the service worker that shows pushes
Maintainers
Readme
@codefusion-cc/web-push
Web Push on WebCrypto alone, for Cloudflare Workers and any runtime with WebCrypto and fetch: VAPID keys and
tokens (RFC 8292), payloads encrypted for the one browser that subscribed (RFC 8291, aes128gcm), sending with a
clear outcome, the browser side of subscribing and of showing notifications, and the service worker that shows
each push. Its one dependency is @codefusion-cc/workers-crypto, for base64url.
npm install @codefusion-cc/web-pushA key for the app
codefusion-console cloudflare workers:edit -- sh -c 'npx -p @codefusion-cc/web-push codefusion-vapid | npx wrangler secret put VAPID_PRIVATE_KEY'The key (a private JWK) goes straight into the secret; its public half is printed for reference. Wrangler runs
with a token minted for this one command, which codefusion-console
deletes when it exits: no wrangler login on the Mac. The secret may
also be the { publicKey, privateKey, subject } JSON other generators print. A new key ends every existing
subscription: browsers subscribe again with it.
In the Worker
import { loadVapidKey, parseSubscription, sendPush } from '@codefusion-cc/web-push'
// The public key the browser subscribes with (null: push is not set up here).
const vapid = await loadVapidKey(env.VAPID_PRIVATE_KEY)
const key = vapid?.publicKey ?? null
// A subscription the browser sent: checked, or null.
const subscription = parseSubscription(await request.json())
// One push; resolves, never rejects, for anything a push can meet at run time.
const result = await sendPush({ vapid, subject: 'mailto:[email protected]', subscription, payload: { title: 'Hej', url: '/c/1' }, urgency: 'high' })
if (result.outcome === 'gone') await forget(subscription.endpoint)
if (result.outcome === 'failed') console.warn('push', result.reason, result.status)payloadis JSON-encoded unless it is a string or bytes, and holds at mostMAX_PAYLOAD_BYTES(3993).bodyinstead ofpayloadsends bytes already encrypted for the browser (a device that holds the plaintext).- Outcomes:
sent;gone(404 or 410: the subscription ended, forget it);failedwith areason:endpoint(not a push service; nothing is fetched),payload,subject(nomailto:/https:contact),rejected(the push service answered with an error),timeout(10 s by default) ornetwork. - A TTL outside 0 to four weeks or an unknown urgency throws: those are programming mistakes. A failure of the
runtime's own WebCrypto rejects as itself, from
sendPushand fromloadVapidKeyalike, rather than reading as a bad payload, a network failure or "push not set up"; the next call tries again. - Tokens are signed once per push service every eleven hours or so, and pushes sent at once share one.
- Only the browsers' push services are ever fetched (FCM, Mozilla, Apple, Windows): a stored subscription pointing anywhere else is refused before any request.
In the page
import { notificationWorker, pushSupport, subscribePush, unsubscribePush } from '@codefusion-cc/web-push/browser'
if (pushSupport() === 'ok' && Notification.permission === 'granted') {
const registration = await notificationWorker('/sw.js') // null: no service worker here
if (registration) {
const { subscription, replaced } = await subscribePush({ registration, key })
await tellServer(subscription, replaced) // replaced: an endpoint made with an older key, to forget
}
}
// On signing out:
const ended = await unsubscribePush()pushSupport():ok,install(iPhone and iPad: add to the Home Screen first),deniedorunsupported.notificationWorker()registers the worker once per tab for every caller, and waits (up to 10 s) for a first one to finish installing, since subscribing or showing through it fails before that. It resolves null where there are no service workers or the browser refuses the registration; a registration left with no worker (refused, or its install failed) is tried again on the next call, so a tab open for days recovers.activeRegistration()is the same wait without the sharing.subscribePush()keeps a subscription made with the same key and replaces one made with another, also in browsers that do not report the key (it remembers it). Calls at the same time share one subscription.
Notifications from the page
import { notificationsSupported, requestNotifyPermission, showNotification } from '@codefusion-cc/web-push/browser'
// From a click: asks only while undecided, never again after a refusal.
const permission = await requestNotifyPermission() // 'granted' | 'denied' | 'default' | 'unsupported'
const shownBy = await showNotification('New order', { body: '#12', tag: 'order:12', data: { url: '/admin/orders/12' } }, {
onClick: () => router.navigate('/admin/orders/12'),
})showNotification()shows through the app's service worker when one is active, and otherwise asnew Notification. Android Chrome has only the first (the constructor throws there), so it always tries the worker first. It resolvesservice-worker,page, or null (not allowed, or refused by the system), and never rejects.- A click on the worker's notification goes to its
notificationclick:handlePushesopensdata.url, and with no address brings the app forward as it is. A click on the page's notification focuses the page, callsonClickand closes it. - It checks permission first and shows nothing until the person has allowed notifications.
The service worker
The app's worker is one call, in TypeScript, built into one classic script at /sw.js:
// src/sw.ts
import { handlePushes } from '@codefusion-cc/web-push/service-worker'
handlePushes({
title: 'MyApp', // for a push without a title
icon: '/icon-192.png',
open: 'message', // an open page moves itself (default 'navigate': it loads the address)
pages: (url) => url.pathname.startsWith('/admin'), // which pages are the app's (default: the whole origin)
announce: true, // tell open pages a push arrived
})// vite.config.ts
import { serviceWorker } from '@codefusion-cc/web-push/vite'
export default defineConfig({ plugins: [react(), serviceWorker({ entry: 'src/sw.ts' })] })- A push's payload is
{ title, body, url, tag, icon, appBadge }, each optional. Every push shows a notification, as browsers require; one without a readable payload shows the app's title. appBadgeputs a count on the installed app's icon (the Badging API: home-screen apps on iPhone and iPad, installed apps on desktop); 0 clears it, and a push without it leaves the icon as it was. A browser without the API, or one that refuses it, still shows the notification. In the page,setAppBadge(count)sets it while open, e.g. once the person dealt with what a push counted.- A tag replaces the older notification with the tag and alerts again (
renotify). In the page,closeNotifications(tag)closes those still on screen (all, without a tag), e.g. when the person opens what they were about, so none stays behind for something done. iOS punishes pushes that show nothing, so a push can't close them on a phone by itself: the page does, when it is opened. - A click opens only a path on the app's origin, or an https address
allowLinkaccepts; neverjavascript:ordata:. A page already on the address (or oneshowssays shows it, for an app whose pages have a shorter second address) is focused; otherwise the focused, then a visible, then any page of the app is taken there; with none open, a new window. A notification with no address it may open only brings a page of the app forward, or opensfallbackUrl(default/) when none is open. iconin a push is shown only whenpushIconreturns it (e.g. a signed avatar path).- The worker caches nothing and handles no fetches, and a new version activates at once.
serviceWorker()bundles the entry and what it imports into one file outside the app's hashed chunks, and serves it invite devas well. Deletepublic/sw.jswhen moving to it.
In the page, onPushMessage gets what the worker posts:
import { onPushMessage } from '@codefusion-cc/web-push/browser'
const stop = onPushMessage((message) => {
if (message.type === 'codefusion-push:open') navigate(message.url) // a path on this origin
if (message.type === 'codefusion-push:received') refresh(message.push)
})In an app's tests
@codefusion-cc/web-push/testing is the other side, written from the RFCs rather than from the sending code, so a
test through it checks what the app sends. It runs in Node and in workerd.
import { testBrowser, testPushService, testVapidKey, readVapidAuthorization } from '@codefusion-cc/web-push/testing'
const { secret } = await testVapidKey() // what VAPID_PRIVATE_KEY holds
const phone = await testBrowser() // a subscription, and a browser that decrypts
const service = testPushService(() => 201) // records pushes; an Error thrown is a network failure
await notify({ VAPID_PRIVATE_KEY: secret }, phone.subscription, service.fetch)
expect(await phone.json(service.pushes[0].body)).toEqual({ title: 'Hej' })
expect((await readVapidAuthorization(service.pushes[0].headers.authorization)).verified).toBe(true)testServiceWorker() is a service worker scope with the app's open pages, for the app's worker options:
const worker = testServiceWorker('https://app.test')
await worker.load(() => import('./sw.ts')) // the app's worker file, with this scope as its self
const panel = worker.page('/admin/orders', { focused: true })
await worker.push({ title: 'New order', url: '/admin/orders/7' })
await worker.click()
expect(panel.events).toEqual([{ type: 'codefusion-push:received', push: { title: 'New order', url: '/admin/orders/7' } }, 'focus', { type: 'codefusion-push:open', url: '/admin/orders/7' }])Each page() is also the page's side: its navigator.serviceWorker, with the one registration the pages share,
and its Notification. enter() puts them on the global, so the app's page code runs as in a browser, and returns
the function that takes them off again:
const worker = testServiceWorker('https://shop.test')
await worker.load(() => import('../sw.ts'))
const panel = worker.page('/admin', { permission: 'granted' }) // also answer, notificationConstructor
const leave = panel.enter()
afterEach(leave)
worker.answerRegister('installing') // a first visit: the worker installs until install() or failInstall()
const shown = notify('New order') // the app's code, through showNotification
await worker.install()
expect(worker.shown).toMatchObject([{ title: 'New order' }])
expect(panel.notifications).toEqual([]) // what `new Notification` showed in the page
worker.answerRegister(new DOMException('The operation is insecure.', 'SecurityError'), 'active') // refused once
await syncPush() // the app's code, through notificationWorker and subscribePush
expect(await worker.subscribed!.json(pushes[0].body)).toEqual({ title: 'Hej' }) // the browser it subscribedregister()answers asanswerRegister()says, one answer per call and the last repeating:active(the default),installing, or an Error it rejects with.registeredlists the scripts registered.- The registration's
showNotificationrejects while no worker is active, as browsers' does, and records intoshown. ItspushManagerrefuses as browsers do (no active worker,userVisibleOnlymissing, another key while subscribed, notifications refused) and subscribes atestBrowser, whichsubscribedgives. - What the worker posts to a page reaches its
messagelisteners once the page callsstartMessages()or setsonmessage; before that it waits, as in a browser.onPushMessagedoes the first. - A worker file written against
selfloads too.
Tests
Encryption and tokens are checked through the testing helpers, in Node and in workerd; the browser side against the edge cases of real browsers.
