npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/web-push

v0.1.1

Published

Web Push on WebCrypto alone: VAPID keys and tokens, aes128gcm payloads and sending from a Cloudflare Worker, subscribing and showing notifications in the browser, and the service worker that shows pushes

Readme

@codefusion-cc/web-push

Web Push on WebCrypto alone, for Cloudflare Workers and any runtime with WebCrypto and fetch: VAPID keys and tokens (RFC 8292), payloads encrypted for the one browser that subscribed (RFC 8291, aes128gcm), sending with a clear outcome, the browser side of subscribing and of showing notifications, and the service worker that shows each push. Its one dependency is @codefusion-cc/workers-crypto, for base64url.

npm install @codefusion-cc/web-push

A key for the app

codefusion-console cloudflare workers:edit -- sh -c 'npx -p @codefusion-cc/web-push codefusion-vapid | npx wrangler secret put VAPID_PRIVATE_KEY'

The key (a private JWK) goes straight into the secret; its public half is printed for reference. Wrangler runs with a token minted for this one command, which codefusion-console deletes when it exits: no wrangler login on the Mac. The secret may also be the { publicKey, privateKey, subject } JSON other generators print. A new key ends every existing subscription: browsers subscribe again with it.

In the Worker

import { loadVapidKey, parseSubscription, sendPush } from '@codefusion-cc/web-push'

// The public key the browser subscribes with (null: push is not set up here).
const vapid = await loadVapidKey(env.VAPID_PRIVATE_KEY)
const key = vapid?.publicKey ?? null

// A subscription the browser sent: checked, or null.
const subscription = parseSubscription(await request.json())

// One push; resolves, never rejects, for anything a push can meet at run time.
const result = await sendPush({ vapid, subject: 'mailto:[email protected]', subscription, payload: { title: 'Hej', url: '/c/1' }, urgency: 'high' })
if (result.outcome === 'gone') await forget(subscription.endpoint)
if (result.outcome === 'failed') console.warn('push', result.reason, result.status)
  • payload is JSON-encoded unless it is a string or bytes, and holds at most MAX_PAYLOAD_BYTES (3993).
  • body instead of payload sends bytes already encrypted for the browser (a device that holds the plaintext).
  • Outcomes: sent; gone (404 or 410: the subscription ended, forget it); failed with a reason: endpoint (not a push service; nothing is fetched), payload, subject (no mailto:/https: contact), rejected (the push service answered with an error), timeout (10 s by default) or network.
  • A TTL outside 0 to four weeks or an unknown urgency throws: those are programming mistakes. A failure of the runtime's own WebCrypto rejects as itself, from sendPush and from loadVapidKey alike, rather than reading as a bad payload, a network failure or "push not set up"; the next call tries again.
  • Tokens are signed once per push service every eleven hours or so, and pushes sent at once share one.
  • Only the browsers' push services are ever fetched (FCM, Mozilla, Apple, Windows): a stored subscription pointing anywhere else is refused before any request.

In the page

import { notificationWorker, pushSupport, subscribePush, unsubscribePush } from '@codefusion-cc/web-push/browser'

if (pushSupport() === 'ok' && Notification.permission === 'granted') {
  const registration = await notificationWorker('/sw.js')   // null: no service worker here
  if (registration) {
    const { subscription, replaced } = await subscribePush({ registration, key })
    await tellServer(subscription, replaced) // replaced: an endpoint made with an older key, to forget
  }
}

// On signing out:
const ended = await unsubscribePush()
  • pushSupport(): ok, install (iPhone and iPad: add to the Home Screen first), denied or unsupported.
  • notificationWorker() registers the worker once per tab for every caller, and waits (up to 10 s) for a first one to finish installing, since subscribing or showing through it fails before that. It resolves null where there are no service workers or the browser refuses the registration; a registration left with no worker (refused, or its install failed) is tried again on the next call, so a tab open for days recovers. activeRegistration() is the same wait without the sharing.
  • subscribePush() keeps a subscription made with the same key and replaces one made with another, also in browsers that do not report the key (it remembers it). Calls at the same time share one subscription.

Notifications from the page

import { notificationsSupported, requestNotifyPermission, showNotification } from '@codefusion-cc/web-push/browser'

// From a click: asks only while undecided, never again after a refusal.
const permission = await requestNotifyPermission()   // 'granted' | 'denied' | 'default' | 'unsupported'

const shownBy = await showNotification('New order', { body: '#12', tag: 'order:12', data: { url: '/admin/orders/12' } }, {
  onClick: () => router.navigate('/admin/orders/12'),
})
  • showNotification() shows through the app's service worker when one is active, and otherwise as new Notification. Android Chrome has only the first (the constructor throws there), so it always tries the worker first. It resolves service-worker, page, or null (not allowed, or refused by the system), and never rejects.
  • A click on the worker's notification goes to its notificationclick: handlePushes opens data.url, and with no address brings the app forward as it is. A click on the page's notification focuses the page, calls onClick and closes it.
  • It checks permission first and shows nothing until the person has allowed notifications.

The service worker

The app's worker is one call, in TypeScript, built into one classic script at /sw.js:

// src/sw.ts
import { handlePushes } from '@codefusion-cc/web-push/service-worker'

handlePushes({
  title: 'MyApp',                 // for a push without a title
  icon: '/icon-192.png',
  open: 'message',                // an open page moves itself (default 'navigate': it loads the address)
  pages: (url) => url.pathname.startsWith('/admin'), // which pages are the app's (default: the whole origin)
  announce: true,                 // tell open pages a push arrived
})
// vite.config.ts
import { serviceWorker } from '@codefusion-cc/web-push/vite'
export default defineConfig({ plugins: [react(), serviceWorker({ entry: 'src/sw.ts' })] })
  • A push's payload is { title, body, url, tag, icon, appBadge }, each optional. Every push shows a notification, as browsers require; one without a readable payload shows the app's title.
  • appBadge puts a count on the installed app's icon (the Badging API: home-screen apps on iPhone and iPad, installed apps on desktop); 0 clears it, and a push without it leaves the icon as it was. A browser without the API, or one that refuses it, still shows the notification. In the page, setAppBadge(count) sets it while open, e.g. once the person dealt with what a push counted.
  • A tag replaces the older notification with the tag and alerts again (renotify). In the page, closeNotifications(tag) closes those still on screen (all, without a tag), e.g. when the person opens what they were about, so none stays behind for something done. iOS punishes pushes that show nothing, so a push can't close them on a phone by itself: the page does, when it is opened.
  • A click opens only a path on the app's origin, or an https address allowLink accepts; never javascript: or data:. A page already on the address (or one shows says shows it, for an app whose pages have a shorter second address) is focused; otherwise the focused, then a visible, then any page of the app is taken there; with none open, a new window. A notification with no address it may open only brings a page of the app forward, or opens fallbackUrl (default /) when none is open.
  • icon in a push is shown only when pushIcon returns it (e.g. a signed avatar path).
  • The worker caches nothing and handles no fetches, and a new version activates at once.
  • serviceWorker() bundles the entry and what it imports into one file outside the app's hashed chunks, and serves it in vite dev as well. Delete public/sw.js when moving to it.

In the page, onPushMessage gets what the worker posts:

import { onPushMessage } from '@codefusion-cc/web-push/browser'

const stop = onPushMessage((message) => {
  if (message.type === 'codefusion-push:open') navigate(message.url)   // a path on this origin
  if (message.type === 'codefusion-push:received') refresh(message.push)
})

In an app's tests

@codefusion-cc/web-push/testing is the other side, written from the RFCs rather than from the sending code, so a test through it checks what the app sends. It runs in Node and in workerd.

import { testBrowser, testPushService, testVapidKey, readVapidAuthorization } from '@codefusion-cc/web-push/testing'

const { secret } = await testVapidKey()               // what VAPID_PRIVATE_KEY holds
const phone = await testBrowser()                      // a subscription, and a browser that decrypts
const service = testPushService(() => 201)             // records pushes; an Error thrown is a network failure
await notify({ VAPID_PRIVATE_KEY: secret }, phone.subscription, service.fetch)
expect(await phone.json(service.pushes[0].body)).toEqual({ title: 'Hej' })
expect((await readVapidAuthorization(service.pushes[0].headers.authorization)).verified).toBe(true)

testServiceWorker() is a service worker scope with the app's open pages, for the app's worker options:

const worker = testServiceWorker('https://app.test')
await worker.load(() => import('./sw.ts'))          // the app's worker file, with this scope as its self
const panel = worker.page('/admin/orders', { focused: true })
await worker.push({ title: 'New order', url: '/admin/orders/7' })
await worker.click()
expect(panel.events).toEqual([{ type: 'codefusion-push:received', push: { title: 'New order', url: '/admin/orders/7' } }, 'focus', { type: 'codefusion-push:open', url: '/admin/orders/7' }])

Each page() is also the page's side: its navigator.serviceWorker, with the one registration the pages share, and its Notification. enter() puts them on the global, so the app's page code runs as in a browser, and returns the function that takes them off again:

const worker = testServiceWorker('https://shop.test')
await worker.load(() => import('../sw.ts'))
const panel = worker.page('/admin', { permission: 'granted' })   // also answer, notificationConstructor
const leave = panel.enter()
afterEach(leave)

worker.answerRegister('installing')      // a first visit: the worker installs until install() or failInstall()
const shown = notify('New order')        // the app's code, through showNotification
await worker.install()
expect(worker.shown).toMatchObject([{ title: 'New order' }])
expect(panel.notifications).toEqual([])  // what `new Notification` showed in the page

worker.answerRegister(new DOMException('The operation is insecure.', 'SecurityError'), 'active') // refused once
await syncPush()                         // the app's code, through notificationWorker and subscribePush
expect(await worker.subscribed!.json(pushes[0].body)).toEqual({ title: 'Hej' })  // the browser it subscribed
  • register() answers as answerRegister() says, one answer per call and the last repeating: active (the default), installing, or an Error it rejects with. registered lists the scripts registered.
  • The registration's showNotification rejects while no worker is active, as browsers' does, and records into shown. Its pushManager refuses as browsers do (no active worker, userVisibleOnly missing, another key while subscribed, notifications refused) and subscribes a testBrowser, which subscribed gives.
  • What the worker posts to a page reaches its message listeners once the page calls startMessages() or sets onmessage; before that it waits, as in a browser. onPushMessage does the first.
  • A worker file written against self loads too.

Tests

Encryption and tokens are checked through the testing helpers, in Node and in workerd; the browser side against the edge cases of real browsers.