Push stops losing a notification every time a subscription rotates - #41
Push stops losing a notification every time a subscription rotates#41darbymanning wants to merge 4 commits into
Conversation
`pushsubscriptionchange` fires only inside a service worker, which is the one place postboi had no presence at all. Every app hand-rolled the same three handlers, and the one everybody skipped was the one that matters: when a browser rotates a subscription the stored address is dead, and the gap only closes after a send 410s — one notification silently gone first. `receive()` from `postboi/push/sw` registers all three. It shows the notification, focuses the tab already showing a URL rather than opening a duplicate, and on a rotation re-subscribes with the VAPID key `sync` already bakes, then POSTs the replacement with `old_endpoint` alongside — a swap rather than a leak. Of the frameworks we ship examples for, only SvelteKit builds a service worker; Next, Nuxt, Astro and Remix serve `public/sw.js` verbatim, where an import is a syntax error at worker startup with nothing pointing at the cause. So `bunx postboi init --push` now finds the worker or creates one, and writes the shape that file can run: the import where a bundler builds it, the handlers written out where it doesn't. It refuses to append to a worker that already handles `push` — two handlers is two notifications per send — and re-running is a no-op. Two implementations of one behaviour is what drifts, so both are driven through one fake worker and their recordings compared; a change to one that isn't made to the other fails that test. Also here: `PushPayload` now types both ends of the wire contract, so a field name can't disagree between `webpush.ts` and the worker reading it, and `to_json` became the exported `subscription_json` so a rotation is re-filed in the shape the page filed the first one. The examples move to the generated worker. SvelteKit's stays on `static/sw.js` rather than the bundled import — examples install postboi from npm, so that import only resolves once this ships.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
postboi | 5e06d79 | Commit Preview URL Branch Preview URL |
Aug 19 2026, 04:01 PM |
Pre-1.0 a caret can't cross a minor, so `^0.35.0` never installs 0.36.0 and an example could never use anything the next release added — the Examples job went red for a feature that was fine, and RELEASING.md documented the wait as a step. CI packs the tarball from the commit under test and installs that instead. `npm pack` rather than `bun link` on purpose: it runs prepack and honours `files` and `exports` exactly as `npm publish` would, so the signal the job was really carrying — a path missing from `files`, a broken exports entry, a `.d.ts` that doesn't resolve in someone else's project — still fails here. A symlink would have tested none of that, and would resolve peers from this repo's node_modules rather than the example's. The pins stay: they're what someone copying an example folder installs. They just aren't what CI resolves, so bumping them is now tidiness rather than a release blocker. With that, SvelteKit's example moves to the bundled `src/service-worker.ts` and the real `postboi/push/sw` import — verified by installing the packed tarball into a copy of it and running its own `ci`.
Examples now build against the commit under testPushed as a second commit. The Examples job packs the tarball from the branch and installs that into each example, instead of resolving the
Verified end to end before committing: packed the tarball, installed it into a copy of the SvelteKit example with So the thing held back in the original PR is now in: SvelteKit's example moves to the bundled RELEASING.md Part C is rewritten to match — bumping the pins is now tidiness (it's what someone copying a folder installs) rather than a release blocker, and the local repro loop is spelled out since a plain |
Releasing needed a clean local main and the ability to push tags, which is two requirements too many for something that should be one decision. Actions → Release → type the bump. It runs `scripts/release.sh`, not a copy of it — the script stays the single implementation of the preconditions, the bump, the validation and the commit, and the workflow is just a machine that always has a clean checkout. The one thing it does differently is skip the tag. GitHub deliberately doesn't run workflows for refs pushed with the automatic token, so a tag pushed from inside Actions would land and nothing would publish; instead the workflow dispatches Publish, whose existing `workflow_dispatch` path derives the tag from package.json and creates it. That path already existed for sandboxes that can't push tags — this just makes it the one Actions uses. Still nothing here publishes. Publish remains the only thing that talks to npm, and it re-runs the suite first, so a release started by mistake is stopped by a red test rather than by someone noticing in time. Both branches of the script exercised against a throwaway repo: CI mode pushes the bump commit and creates no tag anywhere, and the local path still tags and pushes both exactly as before.
One-click releasesThird commit. Actions → Release → Run workflow → type It runs The one difference is the tag. GitHub deliberately doesn't run workflows for refs pushed with the automatic token, so a tag pushed from inside Actions would land and nothing would publish. The workflow sets Nothing here publishes. Both branches exercised against a throwaway repo with a local bare origin before committing: CI mode bumped, committed, pushed and created no tag locally or on the remote; the local path still tags and pushes both, unchanged. Worth saying explicitly: this is not publish-on-merge, which I'd argue against for this repo. Nothing in a merge says patch vs minor — the commit style is prose, not conventional commits, and pre-1.0 "breaking changes are minor" is a judgement call. The docs snapshot in part A has ordering baked in that a merge can't satisfy. And docs-only merges would publish, since the site already deploys on push to main. If you want to go further later, the next step is the release-PR pattern: PRs declare their bump, a workflow opens a "Release 0.36.0" PR carrying the bump and the docs snapshot, and merging that publishes. That makes the snapshot a reviewable diff, which is the step that most deserves eyes. This commit doesn't foreclose it. |
`bun add <tarball>` works right up until a lockfile is involved: two examples commit a bun.lock pinning postboi@^0.35.0, and a second resolution for the same name under a lockfile is a conflict bun refuses outright (DependencyLoop). The examples without one installed fine, which is why the first example in the loop passed and the second didn't. Extracting over the installed copy sidesteps resolution entirely. That's complete rather than a shortcut: postboi has no runtime `dependencies`, so the directory is the whole install, and the .bin/postboi symlink the install already made still points into it. Checked against both shapes before pushing this time — the example that failed (cloudflare-workers, with the lockfile) and the one that imports an API npm doesn't have yet (sveltekit, on postboi/push/sw).
The gap
pushsubscriptionchangefires only inside a service worker — the one place postboi had no presence at all. postboi shippedsubscribe(),push(),push.expired()and the frameworksubscription()stores, but deliberately no worker, so every app hand-rolled the same three handlers. The one everybody skips is the one that matters: when a browser rotates a subscription, the address you stored is dead, and the gap only closes after a send 410s — one notification silently gone first.receive()frompostboi/push/swRegisters all three handlers:
push— shows whatpostboi/webpushsent. Never throws: an unparseable payload degrades to a body, because a push handler that throws shows nothing and a browser that sees pushes arrive with no notification revokes the permission.notificationclick— focuses the tab already showing that URL instead of opening a duplicate. Nourlmeans the send didn't ask for navigation, so nothing happens.pushsubscriptionchange— takes Firefox'snewSubscriptionor mints one with the VAPID keybunx postboi syncalready bakes, then POSTs the replacement toregisterwithold_endpointalongside it. Delete that row, store the rest — a swap rather than a leak.notification: (payload) => ({ … })merges over the defaults, covering both the app-name fallback andtag/renotify/actionswith one hook.The CLI does the wiring
Of the frameworks this repo ships examples for, only SvelteKit builds a service worker. Next, Nuxt, Astro and Remix serve
public/sw.jsverbatim, where animportis a syntax error at worker startup with nothing pointing at the cause. So the generated shape isn't a fallback — it's the majority path.bunx postboi init --pushnow finds the worker where the framework keeps it, or creates one where that framework expects it, and writes the shape that file can actually run:src/service-worker.tspostboi/push/swpublic/sw.jsIt refuses to append to a worker that already handles
pushitself (two handlers is two notifications per send) and re-running is a no-op. The page snippet it prints namesservice_workerwhenever the worker isn't at/sw.js— the most common way a fully wired setup still answersno_service_worker.Keeping the two copies honest
Two implementations of one behaviour is exactly the arrangement that drifts, so
src/testing/worker.tsis a vitest-free fake worker that both are driven through —receive()with the fake installed as globals, the generated source with it passed asself— and their recordings compared. A change made to one and not the other fails that test. Mutation-checked: dropping the focus-existing-tab lookup from the generated copy fails it.Also verified against real temp project layouts on disk (Next with
public/, SvelteKit withtsconfig.json, an Astro project with a conflicting handler, a bare directory), plusnode --checkon every generated file.Two contract fixes carried along
PushPayloadinpush/types.tsnow types both ends of the wire contract —webpush.ts'spayload()and the worker reading it. That payload travels as opaque encrypted bytes, so a field-name disagreement surfaces as a notification that never appears rather than an error.to_jsoninpush/client.tsbecame the exportedsubscription_json, so a rotation is re-filed in exactly the shape the page filed the first one.Examples
All five move to the generated worker. SvelteKit's stays on
static/sw.jsrather than the bundled import: the examplesbun installpostboi from npm, sopostboi/push/swonly resolves once this ships. Flipping it is a two-line job for the post-release "Examples: catch up" commit, and it's what gives the docs a runnable example of the import path.Checks
bun run check— 0 errorsbun run lint— cleanbuild:clibundlessvelte-packageemitsdist/push/sw.{js,d.ts};publintclean