Push to browsers (Web Push)
What you need: a project with push switched on. That is all: no Firebase project, no Google or Apple account. Your project made its own Web Push key pair (VAPID) when push started, and browsers subscribe with its public half.
Where it works:
| Browser | Push service it uses | Notes |
|---|---|---|
| Chrome, Edge, Opera | Google's (fcm.googleapis.com) | no Firebase project of yours is involved |
| Firefox | Mozilla's | |
| Safari on macOS | Apple's (web.push.apple.com) | no Apple account needed |
| Safari on iPhone and iPad | Apple's | iOS 16.4 or later, and only once the site is added to the Home Screen |
| Brave | Google's | off by default: see Step 2 |
Your page must be served over HTTPS. While you develop, http://localhost counts as secure.
Step 1: add a service worker
A service worker receives the push while your page is closed. Every browser requires each push to
show something, so it always shows a notification. Save this as sw.js at your site's root, so
its scope covers your whole site:
self.addEventListener('push', (event) => {
const { title = '', body, image, data = {}, url } = event.data?.json() ?? {}
event.waitUntil(self.registration.showNotification(title, { body, image, data: { ...data, url } }))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.notification.data?.url) event.waitUntil(clients.openWindow(event.notification.data.url))
})
What arrives is the notification you sent: title, body, image, url (where a click goes)
and your data, plus snout_push_delivery, the delivery's id (for
receipts).
With @snoutdata/client, webNotification builds showNotification's arguments for you:
import { webNotification } from '@snoutdata/client'
self.addEventListener('push', (event) => {
const { title, options } = webNotification(event.data?.json())
event.waitUntil(self.registration.showNotification(title, options))
})
Step 2: subscribe from your page
Ask for permission on a click: browsers refuse (or quietly ignore) a permission request that does not follow a user action, and a prompt on page load is the one people deny.
import { createClient } from '@snoutdata/client'
const db = createClient('https://<ref>.api.snoutdata.com', '<your anon key>')
subscribeButton.onclick = async () => {
if ((await Notification.requestPermission()) !== 'granted') {
return // the user said no; the browser will not ask again until they change it themselves
}
const registration = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const { data, error } = await db.push.subscribeWeb(registration)
console.log(error ? error.message : `registered device ${data.id}`)
}
subscribeWeb fetches your project's public key, subscribes the browser to its push service with
it, and registers the subscription as a device.
Who the device belongs to. With a user signed in through db.auth, the device is theirs, and
user_ids reaches it. For a page whose visitors never sign in, allow anonymous devices once, as
the project's owner:
update push.settings set anonymous_devices = true;
and register with the anon key alone. You then reach those browsers by device_ids or by a
topic.
Brave switches off the push service Chrome uses, so subscribing fails with "Registration failed - push service error". The user can turn on Use Google services for push messaging in Brave's privacy settings; there is nothing your page can do about it, so say so in your UI.
Without the client library, the same with fetch:
const base = 'https://<ref>.api.snoutdata.com/push/v1'
const headers = { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }
const { key } = await (await fetch(`${base}/vapid-public-key`, { headers })).json()
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: Uint8Array.from(atob(key.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
})
const { keys } = subscription.toJSON()
await fetch(`${base}/devices`, {
method: 'POST',
headers,
body: JSON.stringify({ transport: 'web', token: subscription.endpoint, p256dh: keys.p256dh, auth: keys.auth }),
})
Step 3: send a notification
To the device your page printed, from the SQL editor:
select push.send('{"title": "Hello", "body": "From SQL.", "url": "https://example.com"}',
device_ids => array['<the device id>'::uuid]);
or to everything a user has registered, with user_ids => array['<the user id>'::uuid].
Sending has the rest.
Step 4: check it worked
A system notification appears, and clicking it opens url. In the log:
select d.status, d.error, split_part(v.token, '/', 3) as push_service
from push.deliveries d join push.devices v on v.id = d.device_id
order by d.id desc limit 5;
accepted, with the browser's push service beside it.
If the delivery is accepted and nothing shows:
- Send once more. The first notification from a site that was just allowed can land quietly in the operating system's notification list, without a banner.
- On a Mac, open System Settings, Notifications: the browser must be allowed to show notifications (and, for Safari, the site in the list under it). A Focus mode hides banners.
- In the browser, the site's notification permission must be Allow (the padlock or site settings in the address bar).
More in Troubleshooting.
Unsubscribing
A user who turns notifications off in your app should also lose the device: call
db.push.unregister(deviceId) (or DELETE /push/v1/devices/<id>), then
(await registration.pushManager.getSubscription())?.unsubscribe(). A browser that revokes the
permission on its own is noticed on the next send: the push service answers that the subscription
is gone, and the device is switched off.