Last time I wrote about keeping a WebSocket alive inside a Manifest V3 extension, and the whole trick was staying awake: heartbeats, reconnects, guards. That solved the session that's already running. It did nothing for the case that actually matters most to a planning-poker tool, which is the teammate who isn't there yet.
Here's the moment. The facilitator points a ticket in Repoker. Three people are in the room. The fourth is deep in an editor somewhere, no GitHub tab open, no room open, nothing. The team either waits or pings them on Slack, which is exactly the busywork this tool exists to remove. I wanted their machine to just... tap them on the shoulder.
You can't hold a connection open for that. Chrome kills an idle extension service worker in about thirty seconds, and no heartbeat can run when there's nothing alive to send it. The primitive for this is the opposite of staying awake: let the worker die, and have the browser's push service resurrect it for one job. That's Web Push, and getting it working from a Cloudflare Worker turned out to be the most interesting crypto detour I've taken in a while.
The extension half is almost suspiciously easy
Extensions have been able to use the standard Push API since Chrome 88. You add the notifications permission, and the background service worker subscribes like any web page would:
sub = await sw.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToBytes(VAPID_PUBLIC_KEY),
});
The extension posts that subscription (an endpoint URL plus two keys) to Repoker's API, tied to the signed-in user. Receiving is a push event listener that shows a notification and stores the click target:
sw.addEventListener("push", (event) => {
const data = event.data.json();
event.waitUntil(
ext.notifications.create(data.url, {
type: "basic",
iconUrl: ext.runtime.getURL("icons/icon-128.png"),
title: `Vote now ยท ${data.roomName}`,
message: `Estimating: ${data.title}`,
}),
);
});
Clicking it opens the GitHub issue, where the extension's on-page nudge takes over. The service worker was dead the entire time until the push arrived, woke it, and let it die again. No keepalive, no polling. It's the piece of MV3 that finally works with the disposable-worker model instead of against it.
So the client took an afternoon. The server is where the story is.
Nobody hands you Web Push on a Worker
A push endpoint won't accept a plain JSON POST. Two RFCs stand between you and that notification: RFC 8292 (VAPID, a signed JWT proving the push came from your server) and RFC 8291 (the payload must be encrypted, per message, for that one subscription).
The usual answer is the web-push npm package, which leans on Node's crypto and https modules. On a Cloudflare Worker I'd rather own the hundred-odd lines of Web Crypto than fight compatibility shims, and honestly, I wanted to see the machinery. It fits in one file.
VAPID is the friendlier half. Build a tiny JWT claiming the push service's origin as audience, sign it ES256 with a private key only the Worker holds:
const aud = new URL(endpoint).origin;
const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60;
const payload = b64urlEncode(enc.encode(JSON.stringify({ aud, exp, sub: cfg.subject })));
const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, enc.encode(`${header}.${payload}`));
return `vapid t=${signingInput}.${b64urlEncode(sig)}, k=${cfg.publicKey}`;
The matching public key ships inside the extension, and the browser binds every subscription to it. Steal a database of subscriptions and they're useless without the Worker's private key.
The encryption half is where you slow down and read carefully. Each message derives fresh keys: an ephemeral ECDH agreement against the subscription's public key, mixed with the subscription's auth secret through two rounds of HKDF, producing a 16-byte content key and a 12-byte nonce:
const keyInfo = concat(enc.encode("WebPush: info\0"), uaPublic, asPublic);
const ikm = await hkdf(authSecret, ecdh, keyInfo, 32);
const cek = await hkdf(salt, ikm, enc.encode("Content-Encoding: aes128gcm\0"), 16);
const nonce = await hkdf(salt, ikm, enc.encode("Content-Encoding: nonce\0"), 12);
Then AES-128-GCM over the payload plus a one-byte delimiter, prefixed with a small header carrying the salt and the ephemeral public key. Every string in those info parameters matters, including the null terminators. Get one byte wrong and nothing tells you which one; the push service just says no, or worse, says 201 and the browser silently drops a message it can't decrypt.
When I grow up, I want to be a watermelon
That failure mode, wrong byte, no error, is why the best part of RFC 8291 is section 5. The spec authors ship a complete worked example: fixed keys, a fixed salt, and the plaintext "When I grow up, I want to be a watermelon", all the way down to the exact bytes of the final encrypted message.
Which means the encryption core is testable in the strictest possible sense. I'd factored mine to accept an injected key and salt precisely so a test could pin it to that vector, and then, I'll admit, shipped without writing the test. The comment claiming testability sat there for a week until writing this post shamed me into it. It passes, byte for byte:
const out = await aes128gcmEncrypt(
new TextEncoder().encode("When I grow up, I want to be a watermelon"),
uaPublic, authSecret, asPrivate, asPublic, salt,
);
expect(b64urlEncode(out)).toBe(VECTOR.message);
If you implement this yourself, write that test first. It converts "why is the push service rejecting me" from an evening of guesswork into a failing assertion.
Deciding who gets woken up
The crypto is fussy but bounded. The design question with actual judgment in it is: when a round starts, whose machine buzzes?
Repoker's answer lives in the room's Durable Object. When the facilitator points a ticket, it collects the room's members, subtracts the facilitator, subtracts everyone currently connected (they can already see the round), and sends an encrypted push to whoever remains. Dead subscriptions answer 404 or 410 and get pruned on the spot.
Then there's the rule I'd argue matters more than all the cryptography: one push per room per fifteen minutes. A facilitator flipping through ten tickets in a pointing session is one event, "we're estimating now", not ten notifications. The cooldown is even reserved before sending, so a burst of activity can't race past it. Have you ever kept notifications on for an app that pinged you per item instead of per session? Neither has anyone else, for long. A notification channel is trust you spend; the fastest way to lose it is to be technically correct about every individual event.
There's a toggle in the extension popup to turn the whole thing off, and signing out unsubscribes. That's table stakes, but it's the kind of table stakes that store reviewers now rightly ask you to document.
Where it stands
One honest limitation: this is Chrome-first for now. Firefox runs MV3 backgrounds as event pages rather than service workers, so there's no registration.pushManager there and the subscribe step quietly skips. The rest of the extension works fine in Firefox; the shoulder-tap is a follow-up.
Put together with the last post, the nudge now has two layers. If you're looking at the ticket, the Point in Repoker button lights up and shows who's waiting. If you're looking at nothing at all, your OS taps you: Vote now ยท Sprint 29. Click, and you're voting.
Both are live in Repoker, on the Chrome Web Store and Firefox Add-ons. And if you're building anything on Workers that needs to reach a browser that isn't looking, the watermelon is waiting for you in section 5.
Top comments (0)