Skip to content

Push stops losing a notification every time a subscription rotates - #41

Open
darbymanning wants to merge 4 commits into
mainfrom
feature/web-push-sw-helper-53f1e4
Open

Push stops losing a notification every time a subscription rotates#41
darbymanning wants to merge 4 commits into
mainfrom
feature/web-push-sw-helper-53f1e4

Conversation

@darbymanning

Copy link
Copy Markdown
Collaborator

The gap

pushsubscriptionchange fires only inside a service worker — the one place postboi had no presence at all. postboi shipped subscribe(), push(), push.expired() and the framework subscription() 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() from postboi/push/sw

// src/service-worker.ts
import { receive } from "postboi/push/sw"

receive({ register: "/push/subscriptions" })

Registers all three handlers:

  • push — shows what postboi/webpush sent. 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. No url means the send didn't ask for navigation, so nothing happens.
  • pushsubscriptionchange — takes Firefox's newSubscription or mints one with the VAPID key bunx postboi sync already bakes, then POSTs the replacement to register with old_endpoint alongside 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 and tag/renotify/actions with 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.js verbatim, where an import is 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 --push now 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:

Subscriptions expire and browsers rotate them. Only a service worker hears about it.
Create public/sw.js to receive notifications? (Y/n)
  Endpoint a subscription is filed at (/push/subscriptions)

✓ created public/sw.js (handlers written out — this file is served as-is and can't import)

  On the page:
    subscription({ register: "/push/subscriptions" })
Framework File Shape
SvelteKit src/service-worker.ts Built — imports postboi/push/sw
Next, Nuxt, Astro, Remix public/sw.js Served as-is — handlers written out, VAPID key baked in

It refuses to append to a worker that already handles push itself (two handlers is two notifications per send) and re-running is a no-op. The page snippet it prints names service_worker whenever the worker isn't at /sw.js — the most common way a fully wired setup still answers no_service_worker.

Keeping the two copies honest

Two implementations of one behaviour is exactly the arrangement that drifts, so src/testing/worker.ts is a vitest-free fake worker that both are driven through — receive() with the fake installed as globals, the generated source with it passed as self — 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 with tsconfig.json, an Astro project with a conflicting handler, a bare directory), plus node --check on every generated file.

Two contract fixes carried along

  • PushPayload in push/types.ts now types both ends of the wire contract — webpush.ts's payload() 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_json in push/client.ts became the exported subscription_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.js rather than the bundled import: the examples bun install postboi from npm, so postboi/push/sw only 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

  • 24 new tests, 795 passing
  • bun run check — 0 errors
  • bun run lint — clean
  • build:cli bundles
  • svelte-package emits dist/push/sw.{js,d.ts}; publint clean

`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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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`.
@darbymanning

Copy link
Copy Markdown
Collaborator Author

Examples now build against the commit under test

Pushed as a second commit. The Examples job packs the tarball from the branch and installs that into each example, instead of resolving the ^0.35.0 pins.

npm pack rather than bun link, deliberately — it runs prepack and honours files and exports exactly as npm publish does, 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.

Verified end to end before committing: packed the tarball, installed it into a copy of the SvelteKit example with bun add, and ran that example's own cisvelte-check passes on import { receive } from "postboi/push/sw", an API that doesn't exist on npm.

So the thing held back in the original PR is now in: SvelteKit's example moves to the bundled src/service-worker.ts with the real import, and the docs' "runnable example" link demonstrates the import path rather than only the generated one. static/sw.js is gone from that example; the other four stay on the generated public/sw.js, which is correct for them.

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 bun install in an example resolves the pin instead.

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.
@darbymanning

Copy link
Copy Markdown
Collaborator Author

One-click releases

Third commit. Actions → Release → Run workflow → type X.Y.Z (or patch/minor/major). No clean local main, no tag-push permission.

It runs scripts/release.sh rather than a copy of it — the script stays the single implementation of the preconditions, bump, validation and commit, and the workflow is just a machine that always has a clean checkout.

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 RELEASE_SKIP_TAG=1, pushes only the bump commit, and dispatches Publish — whose existing workflow_dispatch path already derives the tag from package.json and creates it. That route existed for sandboxes that can't push tags; this makes it the one Actions uses.

Nothing here publishes. publish.yml is still the only thing that talks to npm, and it re-runs the full suite first — so a release started by mistake is stopped by a red test rather than by someone catching it in time.

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant