Web push notifications
Punk::Push reaches a user who has closed the tab, through the browser vendor's push service. The message is encrypted end to end, so the push service relays bytes it cannot read.
package MyApp;
use Punk;
use Punk::Plugin::Push; # compile time
host 'https://example.com';
plugin 'Push' => { # runtime: the configuration
subject => 'mailto:ops@example.com',
public_key => { '$env' => 'VAPID_PUBLIC' },
private_key => { '$env' => 'VAPID_PRIVATE' },
};
post '/reports' => sub {
my ($c) = @_;
$c->push_send($c->auth_id, {
title => 'Your report is ready',
body => 'Three pages, as usual.',
url => '/reports/2026-09',
});
return $c->json({ ok => 1 });
};
Before anything works
Four things have to be true before a notification appears, and three of them are the browser's rules rather than the plugin's. This is the section to read first, because "nothing happens" is the usual first experience.
HTTPS. The Push API needs a secure context. localhost counts;
plain http:// on any other host does not.
A keypair, generated once. punk push keys prints two environment
lines. Keep them: every subscription a browser makes is bound to the
public key it was made with, so replacing the pair makes every existing
subscription undeliverable. The plugin refuses to invent one - a key
minted at boot would differ per worker and per restart, and every
failure would look like an intermittent push service.
Permission, asked from a user gesture. Notification.requestPermission()
must run in response to a click, not on page load. Once denied it
cannot be re-requested - the user has to change it in browser settings
- so ask when there is something worth subscribing to.
A service worker. Push messages are delivered to a worker, not to a page, and the page may not even be open.
What it serves
GET /push/key the VAPID public key, for the browser
POST /push/subscribe store a subscription (guarded)
POST /push/unsubscribe remove one (guarded)
GET /push/push.js the client half (assets => 1)
GET /push/push-sw.js a minimal service worker (assets => 1)
prefix moves all five.
Subscribe and unsubscribe are guarded, and an application with no
auth and no guard is refused at boot. A subscription belongs to a
user, so an unauthenticated POST that writes one lets anybody who can
guess a user id register their own browser for that user's
notifications - a disclosure bug with a delivery mechanism attached.
/push/key is not guarded; it is a public key.
The browser half
<script type="module">
import { subscribe } from '/push/push.js';
document.querySelector('#enable')
.addEventListener('click', () => subscribe());
</script>
The shipped push.js is deliberately small. The one piece worth having
is urlBase64ToUint8Array: applicationServerKey wants a Uint8Array,
not the base64url string the server hands out, and that conversion is
where people get it wrong. Funky-Frame has a
fuller service-worker implementation if you want lifecycle handling.
The worker needs Service-Worker-Allowed. A service worker's scope
defaults to the directory it is served from, so one at
/push/push-sw.js can only control /push/* and the browser refuses to
register it for the site: the path of the provided scope ('/') is not
under the max scope allowed ('/push/'). The plugin sends
Service-Worker-Allowed: / with it. If you serve the worker yourself,
put it at the site root or send the same header.
A service worker outlives your deploy. The browser keeps the one it
has and updates it on its own schedule, so once installed browsers are
asking for /push/push-sw.js you cannot simply stop serving it. Past a
demo, copy both files into your own tree and set assets => 0. There is
also only one service worker per scope: if you already register one for
offline support, merge the push handlers into it rather than
registering a second, or whichever registered last wins.
Sending
$c->push_send($user_id, \%payload) fans out over every subscription
that user has - people have a phone and a laptop - and returns a
result each. One failure
does not abort the rest, and a user with no subscriptions is an empty
list rather than an error.
The payload is yours; the shipped service worker understands title,
body, icon and url.
There is a hard size limit and it is smaller than it looks. RFC 8291
guarantees only 4096 octets of encrypted payload, and the encoding
spends 86 bytes on the record header, one on the delimiter and 16 on the
tag first. An oversize payload croaks with the measured size rather than
earning a 413 per subscription.
topic is worth knowing about: a push service replaces an undelivered
message carrying the same topic rather than queueing both, which is the
difference between a phone showing one badge on reconnect and thirty.
A 410 means gone, and gone means deleted
A 404 or 410 from the push service means the subscription is
permanently gone, and the row is deleted. Anything else - a 5xx
included - leaves it alone.
The asymmetry is the point. Deleting on the wrong signal costs a subscription that cannot be recreated without the user, since they must grant permission again and browsers make asking twice deliberately hard. Keeping a dead one costs a wasted request.
It sends on the worker's own loop
Inside a request the send goes through $c->ua - the one
Fetch agent per worker that Punk binds
to the same event loop that serves inbound requests. A handler waiting on
a push service therefore costs no capacity.
Outside a request - a job, a cron, punk push send - there is no worker
loop to join, so the plugin builds its own agent and the call blocks.
Slower, never broken.
send_to and send take either a context or the application; hand them
the context when you have one.
Retries
With queue => 0, the default, a 5xx is reported to the caller and
not retried: the plugin does not queue, sleep or retry on its own.
queue => 1 hands delivery to Punk::Queue, which
is where retries and backoff already live.
Storage
A push_subscriptions table, shipped as a model so you do not have to
declare one. endpoint is unique and that is load-bearing: a browser
re-subscribing produces the same endpoint, and without the constraint
every re-subscribe adds a row and one send fans out across the
duplicates.
sqitch => 1 ships the DDL as the punk_push project when
Punk::Sqitch is installed; the
DDL is in the model's POD otherwise.
Why is nothing happening
In the order worth checking:
- Is the page on HTTPS or
localhost? - Did
Notification.permissioncome backgranted? Oncedenied, only the user can change it. - Is a service worker registered and activated? The application tab in devtools shows it.
- Did
/push/subscribereturn 200? A 401 means the guard rejected you; a 400 names what was wrong with the subscription. - Did the send report a 2xx? A
400from the push service is almost always the VAPID token - anexpmore than 24 hours out, or anaudthat does not match the endpoint.
A push service accepting a message is not a guarantee the device shows it. Delivery is best-effort by definition, and the plugin reports what the service said and nothing more.