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.permission come back granted? Once denied, only the user can change it.
  • Is a service worker registered and activated? The application tab in devtools shows it.
  • Did /push/subscribe return 200? A 401 means the guard rejected you; a 400 names what was wrong with the subscription.
  • Did the send report a 2xx? A 400 from the push service is almost always the VAPID token - an exp more than 24 hours out, or an aud that 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.